Skip to content

Commit 9bd7f1e

Browse files
committed
impr(CLDSRV-853): Refactor rate limit configuration to use joi schema
1 parent 6bf7a53 commit 9bd7f1e

1 file changed

Lines changed: 146 additions & 172 deletions

File tree

lib/api/apiUtils/rateLimit/config.js

Lines changed: 146 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const assert = require('assert');
1+
const Joi = require('@hapi/joi');
22
const { errors, ArsenalError } = require('arsenal');
33

44
const { rateLimitDefaultConfigCacheTTL, rateLimitDefaultBurstCapacity } = require('../../../../constants');
@@ -48,6 +48,32 @@ const { calculateInterval } = require('./gcra');
4848
* "defaultBurstCapacity": 1
4949
* },
5050
*
51+
* // Optional: Account-level rate limiting configuration
52+
* "account": {
53+
* // Optional: Global default rate limit for all accounts
54+
* // Can be overridden per-bucket via PutBucketRateLimit API
55+
* "defaultConfig": {
56+
* "requestsPerSecond": {
57+
* // Required: Requests per second limit (0 = unlimited)
58+
* "limit": 100,
59+
*
60+
* // Optional: Burst capacity (allows temporary spikes)
61+
* // Default: 1 (no burst allowed)
62+
* // Higher values allow more requests in quick succession
63+
* "burstCapacity": 2
64+
* }
65+
* },
66+
*
67+
* // Optional: How long to cache rate limit configs (milliseconds)
68+
* // Default: 30000 (30 seconds)
69+
* // Higher values = better performance, slower config updates
70+
* "configCacheTTL": 30000,
71+
*
72+
* // Optional: Default burst capacity for per-bucket configs
73+
* // Default: 1
74+
* "defaultBurstCapacity": 1
75+
* },
76+
*
5177
* // Optional: Custom error response for rate limited requests
5278
* "error": {
5379
* // Optional: HTTP status code (400-599)
@@ -125,6 +151,91 @@ const { calculateInterval } = require('./gcra');
125151
* - Enforced atomically in Redis during token grants using GCRA
126152
*/
127153

154+
/**
155+
* RateLimitClassConfig
156+
* @typedef {Object} RateLimitClassConfig
157+
* @property {object} defaultConfig - Default config applied if no resource specific configuration is found.
158+
* @property {number} configCacheTTL - Number of milliseconds to cache per resource configs
159+
* @property {number} defaultBurstCapacity - Default used if resource does not specify a burst capacity.
160+
*/
161+
162+
const rateLimitClassConfigSchema = Joi.object({
163+
defaultConfig: Joi.object({
164+
requestsPerSecond: Joi.object({
165+
limit: Joi.number().integer().min(0).required(),
166+
burstCapacity: Joi.number().positive(),
167+
}),
168+
}),
169+
configCacheTTL: Joi.number().integer().positive(),
170+
defaultBurstCapacity: Joi.number().integer().positive(),
171+
});
172+
173+
const rateLimitConfigSchema = Joi.object({
174+
enabled: Joi.boolean().default(false),
175+
serviceUserArn: Joi.string().required(),
176+
nodes: Joi.number().integer().positive().default(1),
177+
tokenBucketBufferSize: Joi.number().integer().positive().default(50),
178+
tokenBucketRefillThreshold: Joi.number().integer().positive().default(20),
179+
bucket: rateLimitClassConfigSchema,
180+
account: rateLimitClassConfigSchema,
181+
error: Joi.object({
182+
statusCode: Joi.number().integer().min(400).max(599).default(503),
183+
code: Joi.string().default(errors.SlowDown.message),
184+
message: Joi.string().default(errors.SlowDown.description),
185+
}),
186+
});
187+
188+
/**
189+
* Transform validated class config (bucket or account) by calculating intervals
190+
* and applying business logic
191+
*
192+
* @param {string} className - Rate limit class name ('bucket' or 'account')
193+
* @param {object} validatedCfg - Already validated config from Joi
194+
* @param {number} clusters - Number of worker processes spawned per instance
195+
* @param {number} nodes - Number of instances that requests will be load balanced across
196+
* @returns {RateLimitClassConfig} Transformed rate limit config
197+
*/
198+
function transformClassConfig(className, validatedCfg, clusters, nodes) {
199+
const transformed = {
200+
defaultConfig: undefined,
201+
configCacheTTL: validatedCfg.configCacheTTL || rateLimitDefaultConfigCacheTTL,
202+
defaultBurstCapacity: validatedCfg.defaultBurstCapacity || rateLimitDefaultBurstCapacity,
203+
};
204+
205+
if (validatedCfg.defaultConfig?.requestsPerSecond) {
206+
const { limit, burstCapacity } = validatedCfg.defaultConfig.requestsPerSecond;
207+
208+
// Validate limit against nodes AND workers (business rule)
209+
const minLimit = nodes * clusters;
210+
if (limit > 0 && limit < minLimit) {
211+
throw new Error(
212+
`rateLimiting.${className}.defaultConfig.` +
213+
`requestsPerSecond.limit (${limit}) must be >= ` +
214+
`(nodes x workers = ${nodes} x ${clusters} = ${minLimit}) ` +
215+
'or 0 (unlimited). Each worker enforces limit/nodes/workers locally. ' +
216+
`With limit < ${minLimit}, per-worker rate would be < 1 req/s, effectively blocking traffic.`
217+
);
218+
}
219+
220+
// Use provided burstCapacity or fall back to default
221+
const effectiveBurstCapacity = burstCapacity || transformed.defaultBurstCapacity;
222+
223+
// Calculate per-worker interval using distributed architecture
224+
const interval = calculateInterval(limit, nodes, clusters);
225+
226+
// Store both the original limit and the calculated values
227+
transformed.defaultConfig = {
228+
limit,
229+
requestsPerSecond: {
230+
interval,
231+
bucketSize: effectiveBurstCapacity * 1000,
232+
},
233+
};
234+
}
235+
236+
return transformed;
237+
}
238+
128239
/**
129240
* Parse and validate the complete rate limiting configuration
130241
*
@@ -134,195 +245,58 @@ const { calculateInterval } = require('./gcra');
134245
* @throws {Error} If configuration is invalid
135246
*/
136247
function parseRateLimitConfig(rateLimitingConfig, clusters) {
137-
const parsed = {
138-
enabled: true,
139-
};
140-
141-
// Validate and set serviceUserArn
142-
assert.strictEqual(
143-
typeof rateLimitingConfig.serviceUserArn, 'string',
144-
'rateLimiting.serviceUserArn must be a string'
248+
// Validate configuration using Joi schema
249+
const { error: validationError, value: validated } = rateLimitConfigSchema.validate(
250+
rateLimitingConfig,
251+
{ abortEarly: false, allowUnknown: false, convert: false }
145252
);
146-
parsed.serviceUserArn = rateLimitingConfig.serviceUserArn;
147253

148-
// Parse and validate node count
149-
if (rateLimitingConfig.nodes !== undefined) {
150-
assert(
151-
typeof rateLimitingConfig.nodes === 'number' &&
152-
Number.isInteger(rateLimitingConfig.nodes) &&
153-
rateLimitingConfig.nodes > 0,
154-
'rateLimiting.nodes must be a positive integer'
155-
);
156-
parsed.nodes = rateLimitingConfig.nodes;
157-
} else {
158-
parsed.nodes = 1; // Default to 1 node
254+
if (validationError) {
255+
const details = validationError.details.map(d => d.message).join('; ');
256+
throw new Error(`rateLimiting configuration is invalid: ${details}`);
159257
}
160258

161-
if (rateLimitingConfig.tokenBucketBufferSize !== undefined) {
162-
assert(
163-
typeof rateLimitingConfig.tokenBucketBufferSize === 'number' &&
164-
Number.isInteger(rateLimitingConfig.tokenBucketBufferSize) &&
165-
rateLimitingConfig.tokenBucketBufferSize > 0,
166-
'rateLimiting.tokenBucketBufferSize must be a positive integer'
167-
);
168-
parsed.tokenBucketBufferSize = rateLimitingConfig.tokenBucketBufferSize;
169-
} else {
170-
parsed.tokenBucketBufferSize = 50; // (5 seconds @ 10 req/s)
171-
}
172-
173-
if (rateLimitingConfig.tokenBucketRefillThreshold !== undefined) {
174-
assert(
175-
typeof rateLimitingConfig.tokenBucketRefillThreshold === 'number' &&
176-
Number.isInteger(rateLimitingConfig.tokenBucketRefillThreshold) &&
177-
rateLimitingConfig.tokenBucketRefillThreshold > 0,
178-
'rateLimiting.tokenBucketRefillThreshold must be a positive integer'
179-
);
180-
parsed.tokenBucketRefillThreshold = rateLimitingConfig.tokenBucketRefillThreshold;
181-
} else {
182-
parsed.tokenBucketRefillThreshold = 20;
183-
}
259+
// Initialize parsed config with validated values (including defaults)
260+
const parsed = {
261+
enabled: validated.enabled,
262+
serviceUserArn: validated.serviceUserArn,
263+
nodes: validated.nodes,
264+
tokenBucketBufferSize: validated.tokenBucketBufferSize,
265+
tokenBucketRefillThreshold: validated.tokenBucketRefillThreshold,
266+
};
184267

185-
// Parse bucket configuration
268+
// Transform bucket configuration
186269
// Always initialize bucket config to support per-bucket rate limits set via API
187-
// This ensures caching and default values work even when no global default is configured
188270
parsed.bucket = {
189-
defaultConfig: undefined, // No global default unless specified
190-
configCacheTTL: rateLimitDefaultConfigCacheTTL, // Default cache TTL
191-
defaultBurstCapacity: rateLimitDefaultBurstCapacity, // Default burst capacity for per-bucket configs
271+
defaultConfig: undefined,
272+
configCacheTTL: rateLimitDefaultConfigCacheTTL,
273+
defaultBurstCapacity: rateLimitDefaultBurstCapacity,
192274
};
193275

194-
// Override with user-provided bucket configuration
195-
if (rateLimitingConfig.bucket) {
196-
assert.strictEqual(
197-
typeof rateLimitingConfig.bucket, 'object',
198-
'rateLimiting.bucket must be an object'
199-
);
200-
201-
// Parse default config for buckets (global default applied to all buckets)
202-
// If defaultConfig is specified: Parse and validate the bucket rate limit settings
203-
if (rateLimitingConfig.bucket.defaultConfig) {
204-
const bucketConfig = rateLimitingConfig.bucket.defaultConfig;
205-
206-
// Validate config structure
207-
assert.strictEqual(
208-
typeof bucketConfig, 'object',
209-
'rate limit config must be an object'
210-
);
211-
212-
const limitConfig = {};
213-
214-
if (bucketConfig.requestsPerSecond) {
215-
assert.strictEqual(
216-
typeof bucketConfig.requestsPerSecond, 'object',
217-
'requestsPerSecond must be an object'
218-
);
219-
220-
const { limit } = bucketConfig.requestsPerSecond;
221-
222-
// Validate limit
223-
assert(
224-
typeof limit === 'number' && Number.isInteger(limit) && limit >= 0,
225-
'requestsPerSecond.limit must be a non-negative integer'
226-
);
227-
228-
// Validate limit against nodes AND workers
229-
const minLimit = parsed.nodes * clusters;
230-
if (limit > 0 && limit < minLimit) {
231-
throw new Error(
232-
`requestsPerSecond.limit (${limit}) must be >= ` +
233-
`(nodes × workers = ${parsed.nodes} × ${clusters} = ${minLimit}) ` +
234-
'or 0 (unlimited). Each worker enforces limit/nodes/workers locally. ' +
235-
`With limit < ${minLimit}, per-worker rate would be < 1 req/s, effectively blocking traffic.`
236-
);
237-
}
238-
239-
// Default to global default burst capacity
240-
let burstCapacity = parsed.bucket.defaultBurstCapacity;
241-
242-
// Override if provided in config
243-
if (bucketConfig.requestsPerSecond.burstCapacity !== undefined) {
244-
burstCapacity = bucketConfig.requestsPerSecond.burstCapacity;
245-
assert(
246-
typeof burstCapacity === 'number' && Number.isInteger(burstCapacity) && burstCapacity > 0,
247-
'requestsPerSecond.burstCapacity must be a positive integer'
248-
);
249-
}
250-
251-
// Calculate per-worker interval using distributed architecture
252-
const interval = calculateInterval(limit, parsed.nodes, clusters);
253-
254-
// Store both the original limit and the calculated values
255-
limitConfig.limit = limit;
256-
limitConfig.requestsPerSecond = {
257-
interval,
258-
bucketSize: burstCapacity * 1000,
259-
};
260-
}
261-
262-
parsed.bucket.defaultConfig = limitConfig;
263-
}
276+
if (validated.bucket) {
277+
parsed.bucket = transformClassConfig('bucket', validated.bucket, clusters, parsed.nodes);
278+
}
264279

265-
// Parse config cache TTL
266-
// If configCacheTTL is specified: Override the default cache TTL
267-
if (rateLimitingConfig.bucket.configCacheTTL !== undefined) {
268-
const configCacheTTL = rateLimitingConfig.bucket.configCacheTTL;
269-
assert(
270-
typeof configCacheTTL === 'number' &&
271-
Number.isInteger(configCacheTTL) &&
272-
configCacheTTL > 0,
273-
'rateLimiting.bucket.configCacheTTL must be a positive integer'
274-
);
275-
parsed.bucket.configCacheTTL = configCacheTTL;
276-
}
280+
// Transform account configuration (if provided)
281+
if (validated.account) {
282+
parsed.account = transformClassConfig('account', validated.account, clusters, parsed.nodes);
277283
}
278284

279285
// Parse error configuration (supports any HTTP 4xx/5xx status code)
280286
// Default to SlowDown error
281287
parsed.error = errors.SlowDown;
282288

283289
// Override with custom error if specified
284-
if (rateLimitingConfig.error !== undefined) {
285-
// Validate error is an object
286-
assert.strictEqual(
287-
typeof rateLimitingConfig.error, 'object',
288-
'rateLimiting.error must be an object'
289-
);
290-
291-
// If statusCode is specified, validate and create custom error
292-
if (rateLimitingConfig.error.statusCode !== undefined) {
293-
assert(
294-
typeof rateLimitingConfig.error.statusCode === 'number' &&
295-
Number.isInteger(rateLimitingConfig.error.statusCode) &&
296-
rateLimitingConfig.error.statusCode >= 400 &&
297-
rateLimitingConfig.error.statusCode < 600,
298-
'rateLimiting.error.statusCode must be a valid HTTP status code (400-599)'
299-
);
300-
301-
// Validate error code if provided
302-
const errorCode = rateLimitingConfig.error.code || 'SlowDown';
303-
if (rateLimitingConfig.error.code !== undefined) {
304-
assert.strictEqual(
305-
typeof rateLimitingConfig.error.code, 'string',
306-
'rateLimiting.error.code must be a string'
307-
);
308-
}
290+
if (validated.error) {
291+
const errorCode = validated.error.code || 'SlowDown';
292+
const errorMessage = validated.error.message || errors.SlowDown.description;
309293

310-
// Validate error message if provided
311-
const errorMessage = rateLimitingConfig.error.message || errors.SlowDown.description;
312-
if (rateLimitingConfig.error.message !== undefined) {
313-
assert.strictEqual(
314-
typeof rateLimitingConfig.error.message, 'string',
315-
'rateLimiting.error.message must be a string'
316-
);
317-
}
318-
319-
// Override default with custom Arsenal error
320-
parsed.error = new ArsenalError(
321-
errorCode,
322-
rateLimitingConfig.error.statusCode,
323-
errorMessage
324-
);
325-
}
294+
// Create custom Arsenal error
295+
parsed.error = new ArsenalError(
296+
errorCode,
297+
validated.error.statusCode,
298+
errorMessage
299+
);
326300
}
327301

328302
return parsed;

0 commit comments

Comments
 (0)