Repository: express-rate-limit/express-rate-limit

Issue:
Bug: windowMs does not apply correctly for long durations
### Description

Hi, dear.

I encountered this issue while trying to limit the number of password reset attempts.

I wanted to allow a maximum of 5 password resets per year, so I set the `windowMs` value to 365 days. However, I noticed that the limit value was not resetting as expected. While inspecting related options like keyGenerator, I found that the issue seemed to stem from windowMs.

Interestingly, when I reduced the time window to one hour, it worked correctly.

Maybe the default store, memory-store, uses the setTimeout API to handle the windowMs timer. However, since setTimeout only supports delays up to approximately 24.8 days, it seems the key was deleted and reset prematurely due to this limitation.

I couldn’t find any mention of time duration limits for windowMs in the official documentation. Could you please confirm whether there is a maximum allowed duration?

```
windowMs: 10 * 60 * 1000 // works fine
windowMs: 365 * 24 * 60 * 60 * 1000 // not working
```

### Library version

8.0.1

### Node version

v22.13.1

### Typescript version (if you are using it)

5.8.3

### Module system

ESM

Hints from issue comments:
@kumarayushkumar: @writing-sky 
I think you can use rate-limit-redis@nfriedly: Oh, good point. I didn't realize the maximum setTimeout window was so small. Since that's a limitation of the language/runtime, it's not really something we can change in the library. But it is worth documenting and probably adding a validation check for longer windows.

As @kumarayushkumar suggested, the redis store (or really any external store) would be a good match for your needs. See https://express-rate-limit.mintlify.app/reference/stores

Solution patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index a132c60..b80d6cc 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -3,7 +3,9 @@
 
 name: CI
 
-on: push
+on: 
+ - push
+ - pull_request
 
 jobs:
   lint:
diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx
index e4c4ba5..1d72a23 100644
--- a/docs/reference/configuration.mdx
+++ b/docs/reference/configuration.mdx
@@ -25,12 +25,19 @@ create multiple rate limiters.
 <Note>
 
 For stores that do not implement the `init` function (including all legacy
-stores - see the [data stores page](/reference/stores)), you may need to
-configure this value twice, once here and once on the store. In some cases the
-units also differ (e.g. seconds vs milliseconds).
+stores - see the [data stores page][stores]), you may need to configure this
+value twice, once here and once on the store. In some cases the units also
+differ (e.g. seconds vs milliseconds).
 
 </Note>
 
+Maximum value with the default MemoryStore is `2147483647` (2^31-1, ~28.4 days)
+due to
+[limitations of the `setInterval()` call in node.js](https://nodejs.org/api/timers.html#setintervalcallback-delay-args).
+(Also, all rate limits in the default MemoryStore are reset when the node.js
+process is restarted.) Most other [stores] support larger values and retain
+their state when node.js is reset.
+
 Defaults to `60000` ms (= 1 minute).
 
 ## `limit`
@@ -514,5 +521,8 @@ Supported validations are:
 - [ipv6Subnet](/reference/error-codes#err-erl-ipv6-subnet)
 - [ipv6SubnetOrKeyGenerator](/reference/error-codes#err-erl-ipv6subnet-or-keygenerator)
 - [keyGeneratorIpFallback](/reference/error-codes#err-erl-key-gen-ipv6)
+- [windowMs](/reference/error-codes#err-erl-window-ms)
 
 Defaults to `true`.
+
+[stores]: /reference/stores
diff --git a/docs/reference/error-codes.mdx b/docs/reference/error-codes.mdx
index 970ab4c..9b84192 100644
--- a/docs/reference/error-codes.mdx
+++ b/docs/reference/error-codes.mdx
@@ -377,6 +377,19 @@ account.
 Set `validate: {keyGeneratorIpFallback: false}` in the options to disable the
 check.
 
+### `ERR_ERL_WINDOW_MS`
+
+> Added in `8.1.0`
+
+Node.js (as of version 24.6.0)
+[limits `setInterval()` delays to the max 32-bit signed integer value of `2147483647` milliseconds](https://nodejs.org/api/timers.html#setintervalcallback-delay-args),
+which is about 28.4 days. Accordingly, express-rate-limit checks the value
+before using it in the default MemoryStore and warns when it's out of range.
+
+Longer values can be used with most external [stores](/reference/stores).
+
+Set `validate: {limit: false}` in the options to disable the check.
+
 ## Warnings
 
 ### `WRN_ERL_MAX_ZERO`
diff --git a/source/headers.ts b/source/headers.ts
index 5bb5d30..b011a23 100644
--- a/source/headers.ts
+++ b/source/headers.ts
@@ -98,7 +98,7 @@ export const setDraft6Headers = (
 	response.setHeader('RateLimit-Remaining', info.remaining.toString())
 
 	// Set this header only if the store returns a `resetTime`.
-	if (resetSeconds)
+	if (typeof resetSeconds === 'number')
 		response.setHeader('RateLimit-Reset', resetSeconds.toString())
 }
 
diff --git a/source/memory-store.ts b/source/memory-store.ts
index 4d4ddb1..d556439 100644
--- a/source/memory-store.ts
+++ b/source/memory-store.ts
@@ -2,6 +2,7 @@
 // A memory store for hit counts
 
 import type { ClientRateLimitInfo, Options, Store } from './types.js'
+import type { Validations } from './validations.js'
 
 /**
  * The record that stores information about a client - namely, how many times
@@ -49,6 +50,8 @@ export class MemoryStore implements Store {
 	 */
 	localKeys = true
 
+	constructor(private validations?: Validations) {}
+
 	/**
 	 * Method that initializes the store.
 	 *
@@ -58,6 +61,9 @@ export class MemoryStore implements Store {
 		// Get the duration of a window from the options.
 		this.windowMs = options.windowMs
 
+		// check for a valid value
+		this.validations?.windowMs(this.windowMs)
+
 		// Indicates that init was called more than once.
 		// Could happen if a store was shared between multiple instances.
 		if (this.interval) clearInterval(this.interval)
diff --git a/source/rate-limit.ts b/source/rate-limit.ts
index 65e3a60..c83a04a 100644
--- a/source/rate-limit.ts
+++ b/source/rate-limit.ts
@@ -275,7 +275,9 @@ const parseOptions = (passedOptions: Partial<Options>): Configuration => {
 		standardHeaders,
 		// Note that this field is declared after the user's options are spread in,
 		// so that this field doesn't get overridden with an un-promisified store!
-		store: promisifyStore(notUndefinedOptions.store ?? new MemoryStore()),
+		store: promisifyStore(
+			notUndefinedOptions.store ?? new MemoryStore(validations),
+		),
 		// Print an error to the console if a few known misconfigurations are detected.
 		validations,
 	}
diff --git a/source/validations.ts b/source/validations.ts
index b32eab4..90bb485 100644
--- a/source/validations.ts
+++ b/source/validations.ts
@@ -390,6 +390,27 @@ const validations = {
 			)
 		}
 	},
+
+	/**
+	 * Checks to see if the window duration is greater than 2^32 - 1. This is only
+	 * called by the default MemoryStore, since it uses Node's setInterval method.
+	 *
+	 * See https://nodejs.org/api/timers.html#setintervalcallback-delay-args.
+	 */
+	windowMs(windowMs: number) {
+		const SET_TIMEOUT_MAX = 2 ** 31 - 1
+		if (
+			typeof windowMs !== 'number' ||
+			Number.isNaN(windowMs) ||
+			windowMs < 1 ||
+			windowMs > SET_TIMEOUT_MAX
+		) {
+			throw new ValidationError(
+				'ERR_ERL_WINDOW_MS',
+				`Invalid windowMs value: ${windowMs}${typeof windowMs !== 'number' ? ` (${typeof windowMs})` : ''}, must be a number between 1 and ${SET_TIMEOUT_MAX} when using the default MemoryStore`,
+			)
+		}
+	},
 }
 
 export type Validations = typeof validations



Test patch:
diff --git a/test/library/headers-test.ts b/test/library/headers-test.ts
index adbfabd..d3d19a2 100644
--- a/test/library/headers-test.ts
+++ b/test/library/headers-test.ts
@@ -336,4 +336,48 @@ describe('headers test', () => {
 			})
 		})
 	})
+
+	it('should send RateLimit-Reset header with value 0 when window has just expired for draft-6', async () => {
+		class MockStore implements Store {
+			hits: Map<string, number> = new Map()
+
+			async get(key: string): Promise<ClientRateLimitInfo> {
+				return {
+					totalHits: this.hits.get(key) ?? 0,
+					resetTime: new Date(Date.now()), // Current time - this results in resetSeconds = 0
+				}
+			}
+
+			async increment(key: string): Promise<ClientRateLimitInfo> {
+				const count = (this.hits.get(key) ?? 0) + 1
+				this.hits.set(key, count)
+
+				return {
+					totalHits: count,
+					resetTime: new Date(Date.now()), // Current time - this results in resetSeconds = 0
+				}
+			}
+
+			async decrement(_key: string): Promise<void> {}
+
+			async resetKey(_key: string): Promise<void> {}
+		}
+
+		const app = createServer(
+			rateLimit({
+				windowMs: 60 * 1000,
+				limit: 5,
+				store: new MockStore(),
+				standardHeaders: 'draft-6',
+			}),
+		)
+
+		await request(app)
+			.get('/')
+			.expect('ratelimit-policy', '5;w=60')
+			.expect('ratelimit-limit', '5')
+			.expect('ratelimit-remaining', '4')
+			.expect('ratelimit-reset', '0')
+			.expect(200, 'Hi there!')
+	})
 })
diff --git a/test/library/validation-test.ts b/test/library/validation-test.ts
index a7a6dd0..4d0a3b5 100644
--- a/test/library/validation-test.ts
+++ b/test/library/validation-test.ts
@@ -558,4 +558,17 @@ describe('validations tests', () => {
 			expect(console.error).not.toHaveBeenCalled()
 		})
 	})
+
+	describe('windowMs', () => {
+		it('should warn on large values, but not in-range values', () => {
+			validations.windowMs(5 * 60 * 1000)
+			expect(console.warn).not.toHaveBeenCalled()
+			expect(console.error).not.toHaveBeenCalled()
+
+			validations.windowMs(30 * 24 * 60 * 60 * 1000)
+			expect(console.error).toHaveBeenCalledWith(
+				expect.objectContaining({ code: 'ERR_ERL_WINDOW_MS' }),
+			)
+		})
+	})
 })