1- const assert = require ( 'assert ' ) ;
1+ const Joi = require ( '@hapi/joi ' ) ;
22const { errors, ArsenalError } = require ( 'arsenal' ) ;
33
44const { 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 */
136247function 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