1717'use strict' ;
1818
1919// ── Defaults ────────────────────────────────────────────────────────────
20- const DEFAULT_RPM = 60 ;
20+ const DEFAULT_RPM = 600 ;
2121const DEFAULT_RPH = 1000 ;
2222const DEFAULT_BYTES_PM = 50 * 1024 * 1024 ; // 50 MB
2323
@@ -58,10 +58,16 @@ function advanceWindow(win, now, size) {
5858
5959 // Zero out slots that have expired
6060 const slotsToZero = Math . min ( elapsed , size ) ;
61- for ( let i = 1 ; i <= slotsToZero ; i ++ ) {
62- const slot = ( win . lastSlot + i ) % size ;
63- win . total -= win . counts [ slot ] ;
64- win . counts [ slot ] = 0 ;
61+ if ( slotsToZero >= size ) {
62+ // Full window expired — reset directly to avoid total drift
63+ win . counts . fill ( 0 ) ;
64+ win . total = 0 ;
65+ } else {
66+ for ( let i = 1 ; i <= slotsToZero ; i ++ ) {
67+ const slot = ( win . lastSlot + i ) % size ;
68+ win . total -= win . counts [ slot ] ;
69+ win . counts [ slot ] = 0 ;
70+ }
6571 }
6672
6773 win . lastSlot = now % size ;
@@ -83,25 +89,49 @@ function recordInWindow(win, now, size, value) {
8389}
8490
8591/**
86- * Get the sliding window estimate of the current rate.
87- *
88- * Uses the formula: current_window_count + previous_window_weight * previous_total
89- * where previous_window_weight = (slot_duration - elapsed_in_current_slot) / slot_duration
92+ * Get the current count in the sliding window.
9093 *
91- * This is a simplified but effective approach: we use the total across
92- * all current-window slots plus a weighted fraction of the oldest expired slot's
93- * contribution to approximate the true sliding window.
94+ * After advancing the window to zero out stale slots, returns the
95+ * sum of all active slot counts.
9496 *
9597 * @param {object } win - Window object
9698 * @param {number } now - Current time in the slot's unit
9799 * @param {number } size - Window size
98- * @returns {number } Estimated count in the window
100+ * @returns {number } Count of events in the current window
99101 */
100102function getWindowCount ( win , now , size ) {
101103 advanceWindow ( win , now , size ) ;
102104 return win . total ;
103105}
104106
107+ /**
108+ * Estimate how many time-units until the window count drops below a threshold.
109+ *
110+ * Scans backwards from the oldest slot in the window to find the first
111+ * non-zero slot. That slot will expire in (its age remaining) time-units.
112+ *
113+ * @param {object } win - Window object (must be advanced to `now` first)
114+ * @param {number } now - Current time in the slot's unit
115+ * @param {number } size - Window size
116+ * @param {number } limit - The threshold to drop below
117+ * @returns {number } Estimated time-units until count < limit (minimum 1)
118+ */
119+ function estimateRetryAfter ( win , now , size , limit ) {
120+ // Walk from the oldest slot (now - size + 1) forward, accumulating
121+ // how much capacity is freed as each slot expires.
122+ let freed = 0 ;
123+ for ( let age = size - 1 ; age >= 0 ; age -- ) {
124+ const slot = ( ( now - age ) % size + size ) % size ;
125+ freed += win . counts [ slot ] ;
126+ if ( win . total - freed < limit ) {
127+ // This slot expires in (age + 1) time-units from now
128+ return Math . max ( 1 , age + 1 ) ;
129+ }
130+ }
131+ // Shouldn't happen if total >= limit, but fall back to full window
132+ return Math . max ( 1 , size ) ;
133+ }
134+
105135/**
106136 * Per-provider rate limit state.
107137 */
@@ -119,7 +149,7 @@ class ProviderState {
119149class RateLimiter {
120150 /**
121151 * @param {object } config
122- * @param {number } [config.rpm=60 ] - Max requests per minute
152+ * @param {number } [config.rpm=600 ] - Max requests per minute
123153 * @param {number } [config.rph=1000] - Max requests per hour
124154 * @param {number } [config.bytesPm=52428800] - Max bytes per minute (50 MB)
125155 * @param {boolean } [config.enabled=true] - Whether rate limiting is active
@@ -180,8 +210,8 @@ class RateLimiter {
180210 // Check RPM (requests per minute)
181211 const rpmCount = getWindowCount ( state . rpmWindow , nowSec , MINUTE_SLOTS ) ;
182212 if ( rpmCount >= this . rpm ) {
183- const resetAt = ( nowSec + 1 ) + ( MINUTE_SLOTS - 1 ) ;
184- const retryAfter = Math . max ( 1 , MINUTE_SLOTS - ( nowSec % MINUTE_SLOTS ) ) ;
213+ const retryAfter = estimateRetryAfter ( state . rpmWindow , nowSec , MINUTE_SLOTS , this . rpm ) ;
214+ const resetAt = nowSec + retryAfter ;
185215 return {
186216 allowed : false ,
187217 limitType : 'rpm' ,
@@ -195,7 +225,8 @@ class RateLimiter {
195225 // Check RPH (requests per hour)
196226 const rphCount = getWindowCount ( state . rphWindow , nowMin , HOUR_SLOTS ) ;
197227 if ( rphCount >= this . rph ) {
198- const retryAfter = Math . max ( 1 , ( HOUR_SLOTS - ( nowMin % HOUR_SLOTS ) ) * 60 ) ;
228+ const retryAfterMin = estimateRetryAfter ( state . rphWindow , nowMin , HOUR_SLOTS , this . rph ) ;
229+ const retryAfter = retryAfterMin * 60 ; // convert minutes to seconds
199230 const resetAt = Math . floor ( nowMs / 1000 ) + retryAfter ;
200231 return {
201232 allowed : false ,
@@ -210,7 +241,7 @@ class RateLimiter {
210241 // Check bytes per minute
211242 const bytesCount = getWindowCount ( state . bytesWindow , nowSec , MINUTE_SLOTS ) ;
212243 if ( bytesCount + requestBytes > this . bytesPm ) {
213- const retryAfter = Math . max ( 1 , MINUTE_SLOTS - ( nowSec % MINUTE_SLOTS ) ) ;
244+ const retryAfter = estimateRetryAfter ( state . bytesWindow , nowSec , MINUTE_SLOTS , this . bytesPm ) ;
214245 const resetAt = nowSec + retryAfter ;
215246 return {
216247 allowed : false ,
@@ -271,17 +302,24 @@ class RateLimiter {
271302 const rpmCount = getWindowCount ( state . rpmWindow , nowSec , MINUTE_SLOTS ) ;
272303 const rphCount = getWindowCount ( state . rphWindow , nowMin , HOUR_SLOTS ) ;
273304
305+ const rpmRetry = rpmCount >= this . rpm
306+ ? estimateRetryAfter ( state . rpmWindow , nowSec , MINUTE_SLOTS , this . rpm )
307+ : 0 ;
308+ const rphRetry = rphCount >= this . rph
309+ ? estimateRetryAfter ( state . rphWindow , nowMin , HOUR_SLOTS , this . rph ) * 60
310+ : 0 ;
311+
274312 return {
275313 enabled : true ,
276314 rpm : {
277315 limit : this . rpm ,
278316 remaining : Math . max ( 0 , this . rpm - rpmCount ) ,
279- reset : nowSec + ( MINUTE_SLOTS - ( nowSec % MINUTE_SLOTS ) ) ,
317+ reset : rpmRetry > 0 ? nowSec + rpmRetry : 0 ,
280318 } ,
281319 rph : {
282320 limit : this . rph ,
283321 remaining : Math . max ( 0 , this . rph - rphCount ) ,
284- reset : Math . floor ( nowMs / 1000 ) + ( HOUR_SLOTS - ( nowMin % HOUR_SLOTS ) ) * 60 ,
322+ reset : rphRetry > 0 ? Math . floor ( nowMs / 1000 ) + rphRetry : 0 ,
285323 } ,
286324 } ;
287325 } catch ( _err ) {
@@ -309,15 +347,19 @@ class RateLimiter {
309347 * - AWF_RATE_LIMIT_RPM (default: 60)
310348 * - AWF_RATE_LIMIT_RPH (default: 1000)
311349 * - AWF_RATE_LIMIT_BYTES_PM (default: 52428800)
312- * - AWF_RATE_LIMIT_ENABLED (default: "true" )
350+ * - AWF_RATE_LIMIT_ENABLED (default: "false" — rate limiting is opt-in )
313351 *
314352 * @returns {RateLimiter }
315353 */
316354function create ( ) {
317- const rpm = parseInt ( process . env . AWF_RATE_LIMIT_RPM , 10 ) || DEFAULT_RPM ;
318- const rph = parseInt ( process . env . AWF_RATE_LIMIT_RPH , 10 ) || DEFAULT_RPH ;
319- const bytesPm = parseInt ( process . env . AWF_RATE_LIMIT_BYTES_PM , 10 ) || DEFAULT_BYTES_PM ;
320- const enabled = process . env . AWF_RATE_LIMIT_ENABLED !== 'false' ;
355+ const rawRpm = parseInt ( process . env . AWF_RATE_LIMIT_RPM , 10 ) ;
356+ const rawRph = parseInt ( process . env . AWF_RATE_LIMIT_RPH , 10 ) ;
357+ const rawBytesPm = parseInt ( process . env . AWF_RATE_LIMIT_BYTES_PM , 10 ) ;
358+
359+ const rpm = ( Number . isFinite ( rawRpm ) && rawRpm > 0 ) ? rawRpm : DEFAULT_RPM ;
360+ const rph = ( Number . isFinite ( rawRph ) && rawRph > 0 ) ? rawRph : DEFAULT_RPH ;
361+ const bytesPm = ( Number . isFinite ( rawBytesPm ) && rawBytesPm > 0 ) ? rawBytesPm : DEFAULT_BYTES_PM ;
362+ const enabled = process . env . AWF_RATE_LIMIT_ENABLED === 'true' ;
321363
322364 return new RateLimiter ( { rpm, rph, bytesPm, enabled } ) ;
323365}
0 commit comments