Skip to content
Open
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
55 changes: 29 additions & 26 deletions lib/api/apiUtils/rateLimit/cache.js
Original file line number Diff line number Diff line change
@@ -1,63 +1,66 @@
const configCache = new Map();

// Load tracking for adaptive burst capacity
// Map<bucketKey, Array<timestamp>> - rolling 1-second window
const requestTimestamps = new Map();
const namespace = {
bucket: 'bkt',
};

function setCachedConfig(key, limitConfig, ttl) {
function cacheSet(cache, key, value, ttl) {
const expiry = Date.now() + ttl;
configCache.set(key, { expiry, config: limitConfig });
cache.set(key, { expiry, value });
}

function getCachedConfig(key) {
const value = configCache.get(key);
if (value === undefined) {
function cacheGet(cache, key) {
const cachedValue = cache.get(key);
if (cachedValue === undefined) {
return undefined;
}

const { expiry, config } = value;
const { expiry, value } = cachedValue;
if (expiry <= Date.now()) {
configCache.delete(key);
cache.delete(key);
return undefined;
}

return config;
return value;
}

function cacheDelete(cache, key) {
cache.delete(key);
}

function expireCachedConfigs(now) {
function cacheExpire(cache) {
const now = Date.now();

const toRemove = [];
for (const [key, { expiry }] of configCache.entries()) {
for (const [key, { expiry }] of cache.entries()) {
if (expiry <= now) {
toRemove.push(key);
}
}

for (const key of toRemove) {
configCache.delete(key);
cache.delete(key);
}

return toRemove.length;
}

/**
* Invalidate cached config for a specific bucket
*
* @param {string} bucketName - Bucket name
* @returns {boolean} True if entry was found and removed
*/
function invalidateCachedConfig(bucketName) {
const cacheKey = `bucket:${bucketName}`;
return configCache.delete(cacheKey);
function formatKeyDecorator(fn) {
return (resourceClass, resourceId, ...args) => fn(`${resourceClass}:${resourceId}`, ...args);
}

const getCachedConfig = formatKeyDecorator(cacheGet.bind(null, configCache));
const setCachedConfig = formatKeyDecorator(cacheSet.bind(null, configCache));
const deleteCachedConfig = formatKeyDecorator(cacheDelete.bind(null, configCache));
const expireCachedConfigs = cacheExpire.bind(null, configCache);

module.exports = {
namespace,
setCachedConfig,
getCachedConfig,
expireCachedConfigs,
invalidateCachedConfig,

deleteCachedConfig,
// Do not access directly
// Used only for tests
configCache,
requestTimestamps,
};
129 changes: 71 additions & 58 deletions lib/api/apiUtils/rateLimit/helpers.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
const { config } = require('../../../Config');
const cache = require('./cache');
const constants = require('../../../../constants');
const { getTokenBucket } = require('./tokenBucket');
const { policies: { actionMaps: { actionMapBucketRateLimit } } } = require('arsenal');

const rateLimitApiActions = Object.keys(actionMapBucketRateLimit);

/**
* Get rate limit configuration from cache only (no metadata fetch)
*
* @param {string} bucketName - Bucket name
* @returns {object|null|undefined} Rate limit config, null (no limit), or undefined (not cached)
*/
function getRateLimitFromCache(bucketName) {
const cacheKey = `bucket:${bucketName}`;
return cache.getCachedConfig(cacheKey);
}

/**
* Extract rate limit configuration from bucket metadata and cache it
* Extract rate limit configuration from bucket metadata or global rate limit configuration.
*
* Resolves in priority order:
* 1. Per-bucket configuration (from bucket metadata)
Expand All @@ -30,24 +18,21 @@ function getRateLimitFromCache(bucketName) {
* @param {object} log - Logger instance
* @returns {object|null} Rate limit config or null if no limit
*/
function extractAndCacheRateLimitConfig(bucket, bucketName, log) {
const cacheKey = `bucket:${bucketName}`;
const cacheTTL = config.rateLimiting.bucket?.configCacheTTL ||
constants.rateLimitDefaultConfigCacheTTL;

function extractBucketRateLimitConfig(bucket, bucketName, log) {
// Try per-bucket config first
const bucketConfig = bucket.getRateLimitConfiguration();
if (bucketConfig) {
const data = bucketConfig.getData();
const limitConfig = {
limit: data.RequestsPerSecond.Limit,
burstCapacity: config.rateLimiting.bucket.defaultBurstCapacity * 1000,
source: 'bucket',
};

cache.setCachedConfig(cacheKey, limitConfig, cacheTTL);
log.debug('Extracted per-bucket rate limit config', {
bucketName,
limit: limitConfig.limit,
burstCapacity: config.rateLimiting.bucket.defaultBurstCapacity * 1000,
});

return limitConfig;
Expand All @@ -58,10 +43,10 @@ function extractAndCacheRateLimitConfig(bucket, bucketName, log) {
if (globalLimit !== undefined && globalLimit > 0) {
const limitConfig = {
limit: globalLimit,
burstCapacity: config.rateLimiting.bucket.defaultBurstCapacity * 1000,
source: 'global',
};

cache.setCachedConfig(cacheKey, limitConfig, cacheTTL);
log.debug('Using global default rate limit config', {
bucketName,
limit: limitConfig.limit,
Expand All @@ -71,58 +56,86 @@ function extractAndCacheRateLimitConfig(bucket, bucketName, log) {
}

// No rate limiting configured - cache null to avoid repeated lookups
cache.setCachedConfig(cacheKey, null, cacheTTL);
log.trace('No rate limit configured for bucket', { bucketName });

return null;
}

/**
* Check rate limit with pre-resolved configuration using token reservation
*
* Uses token bucket: Workers maintain local tokens granted by Redis.
* Token consumption is pure in-memory (fast). Refills happen async in background.
*
* @param {string} bucketName - Bucket name
* @param {object|null} limitConfig - Pre-resolved rate limit config
* @param {object} log - Logger instance
* @param {function} callback - Callback(err, rateLimited boolean)
* @returns {undefined}
*/
function checkRateLimitWithConfig(bucketName, limitConfig, log, callback) {
// No rate limiting configured
if (!limitConfig || limitConfig.limit === 0) {
return callback(null, false);
function extractRateLimitConfigFromRequest(request) {
const applyRateLimit = config.rateLimiting?.enabled
&& !rateLimitApiActions.includes(request.apiMethod) // Don't limit any rate limit admin actions
&& !request.isInternalServiceRequest; // Don't limit any calls from internal services

if (!applyRateLimit) {
return { needsCheck: false };
}

const limitConfigs = {};
let needsCheck = false;

if (request.accountLimits) {
limitConfigs.account = {
...request.accountLimits,
source: 'account',
};
needsCheck = true;
}

if (request.bucketName) {
const cachedConfig = cache.getCachedConfig(cache.namespace.bucket, request.bucketName);
if (cachedConfig) {
limitConfigs.bucket = cachedConfig;
needsCheck = true;
}

if (!request.accountLimits) {
const cachedOwner = cache.getCachedBucketOwner(request.bucketName);
if (cachedOwner !== undefined) {
const cachedConfig = cache.getCachedConfig(cache.namespace.account, cachedOwner);
if (cachedConfig) {
limitConfigs.account = cachedConfig;
limitConfigs.bucketOwner = cachedOwner;
needsCheck = true;
}
}
}
}

// Get or create token bucket for this bucket
const tokenBucket = getTokenBucket(bucketName, limitConfig, log);
return { needsCheck, limitConfigs };
}

function checkRateLimitsForRequest(checks, log) {
const buckets = [];
for (const check of checks) {
const bucket = getTokenBucket(check.resourceClass, check.resourceId, check.measure, check.config, log);
if (!bucket.hasCapacity()) {
log.debug('Rate limit check: denied (no tokens available)', {
resourceClass: bucket.resourceClass,
resourceId: bucket.resourceId,
measure: bucket.measure,
limit: bucket.limitConfig.limit,
source: bucket.limitConfig.source,
});

// Try to consume a token (in-memory, no Redis)
const allowed = tokenBucket.tryConsume();
return { allowed: false, rateLimitSource: `${check.resourceClass}:${check.source}`};
}

buckets.push(bucket);

if (allowed) {
log.trace('Rate limit check: allowed (token consumed)', {
bucketName,
tokensRemaining: tokenBucket.tokens,
});
} else {
log.debug('Rate limit check: denied (no tokens available)', {
bucketName,
limit: limitConfig.limit,
source: limitConfig.source,
resourceClass: bucket.resourceClass,
resourceId: bucket.resourceId,
measure: bucket.measure,
source: bucket.limitConfig.source,
});
}

// Return inverse: callback expects "rateLimited" boolean
// allowed=true → rateLimited=false
// allowed=false → rateLimited=true
return callback(null, !allowed);
buckets.forEach(bucket => bucket.tryConsume());
return { allowed: true };
}

module.exports = {
getRateLimitFromCache,
extractAndCacheRateLimitConfig,
checkRateLimitWithConfig,
rateLimitApiActions,
extractBucketRateLimitConfig,
extractRateLimitConfigFromRequest,
checkRateLimitsForRequest,
};
4 changes: 2 additions & 2 deletions lib/api/bucketDeleteRateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const metadata = require('../metadata/wrapper');
const { standardMetadataValidateBucket } = require('../metadata/metadataUtils');
const collectCorsHeaders = require('../utilities/collectCorsHeaders');
const { isRateLimitServiceUser } = require('./apiUtils/authorization/serviceUser');
const { invalidateCachedConfig } = require('./apiUtils/rateLimit/cache');
const cache = require('./apiUtils/rateLimit/cache');
const { removeTokenBucket } = require('./apiUtils/rateLimit/tokenBucket');

/**
Expand Down Expand Up @@ -52,7 +52,7 @@ function bucketDeleteRateLimit(authInfo, request, log, callback) {
return callback(err, corsHeaders);
}
// Invalidate cache and remove token bucket
invalidateCachedConfig(bucketName);
cache.deleteCachedConfig(cache.namespace.bucket, bucketName);
removeTokenBucket(bucketName);
log.debug('invalidated rate limit cache and token bucket for bucket', { bucketName });
// TODO: implement Utapi metric support
Expand Down
4 changes: 2 additions & 2 deletions lib/api/bucketPutRateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const { config } = require('../Config');
const metadata = require('../metadata/wrapper');
const { standardMetadataValidateBucket } = require('../metadata/metadataUtils');
const { isRateLimitServiceUser } = require('./apiUtils/authorization/serviceUser');
const { invalidateCachedConfig } = require('./apiUtils/rateLimit/cache');
const cache = require('./apiUtils/rateLimit/cache');

function parseRequestBody(requestBody, callback) {
try {
Expand Down Expand Up @@ -94,7 +94,7 @@ function bucketPutRateLimit(authInfo, request, log, callback) {
return callback(err, corsHeaders);
}
// Invalidate cache so new limit takes effect immediately
invalidateCachedConfig(bucketName);
cache.deleteCachedConfig(cache.namespace.bucket, bucketName);
log.debug('invalidated rate limit cache for bucket', { bucketName });
// TODO: implement Utapi metric support
return callback(null, corsHeaders);
Expand Down
Loading
Loading