Skip to content

Commit 120962e

Browse files
committed
Added reason for limiting in the check method.
Fixes #7
1 parent 63eb9fd commit 120962e

File tree

5 files changed

+106
-16
lines changed

5 files changed

+106
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- The `check` method now exists on both limiters, and will return a `reason` property.
1213
- `RateLimiterOptions` is now exported.
1314

1415
## [0.6.2] - 2025-06-17

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ export const actions = {
4848
default: async (event) => {
4949
// Every call to isLimited counts as a hit towards the rate limit for the event.
5050
if (await limiter.isLimited(event)) throw error(429);
51+
},
52+
53+
debug: async (event) => {
54+
// Alternatively you can call check to get more details.
55+
// (will also count as a hit)
56+
const status = await limiter.check(event);
57+
if (status.limited) {
58+
// 'IP' | 'IPUA' | 'cookie' | number
59+
console.log('Limited due to ' + status.reason);
60+
throw error(429);
61+
}
5162
}
5263
};
5364
```

src/index.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { RateLimiterPlugin } from '$lib/server/limiters/rateLimiterPlugin.j
33
import { RateLimiter } from '$lib/server/rateLimiter.js';
44
import { RetryAfterRateLimiter } from '$lib/server/retryAfterRateLimiter.js';
55
import type { RequestEvent } from '@sveltejs/kit';
6-
import { describe, it, expect, beforeEach } from 'vitest';
6+
import { describe, it, expect, beforeEach, assert } from 'vitest';
77
import { mock } from 'vitest-mock-extended';
88

99
const hashFunction = async (input: string) => {
@@ -166,7 +166,10 @@ describe('Basic rate limiter', async () => {
166166

167167
expect(await limiter.isLimited(event)).toEqual(false); // 1 1 1
168168
expect(await limiter.isLimited(event)).toEqual(false); // 2 2 2
169-
expect(await limiter.isLimited(event)).toEqual(true); // 3 2 2 (Cookie fails)
169+
expect(await limiter.check(event)).toEqual({
170+
limited: true,
171+
reason: 'cookie'
172+
}); // 3 2 2 (Cookie fails)
170173

171174
event.cookies.delete('testcookie', { path: '/' });
172175

@@ -491,8 +494,9 @@ describe('Retry-After rate limiter', () => {
491494
expect(status).toEqual({ limited: false, retryAfter: 0 });
492495

493496
status = await limiter.check(event);
494-
expect(status.limited).toBe(true);
497+
assert(status.limited);
495498
expect(status.retryAfter.toString()).toMatch(/^[345]$/);
499+
expect(status.reason).toEqual('IPUA');
496500

497501
await delay(5100);
498502

@@ -522,8 +526,9 @@ describe('Retry-After rate limiter', () => {
522526
expect(status).toEqual({ limited: false, retryAfter: 0 });
523527

524528
status = await limiter.check(event);
525-
expect(status.limited).toEqual(true);
529+
assert(status.limited);
526530
expect(status.retryAfter.toString()).toMatch(/^[01]$/);
531+
expect(status.reason).toEqual('IPUA');
527532

528533
event.request.headers.set('User-Agent', 'Safari 2');
529534

@@ -534,23 +539,26 @@ describe('Retry-After rate limiter', () => {
534539
expect(status).toEqual({ limited: false, retryAfter: 0 });
535540

536541
status = await limiter.check(event);
537-
expect(status.limited).toEqual(true);
542+
assert(status.limited);
538543
expect(status.retryAfter).toBeGreaterThanOrEqual(59);
539544
expect(status.retryAfter).toBeLessThanOrEqual(60);
545+
expect(status.reason).toEqual('IP');
540546

541547
event.request.headers.set('User-Agent', 'Safari 3');
542548

543549
status = await limiter.check(event);
544-
expect(status.limited).toEqual(true);
550+
assert(status.limited);
545551
expect(status.retryAfter).toBeGreaterThanOrEqual(59);
546552
expect(status.retryAfter).toBeLessThanOrEqual(60);
553+
expect(status.reason).toEqual('IP');
547554

548555
await delay(1100);
549556

550557
status = await limiter.check(event);
551-
expect(status.limited).toEqual(true);
558+
assert(status.limited);
552559
expect(status.retryAfter).toBeGreaterThanOrEqual(58);
553560
expect(status.retryAfter).toBeLessThanOrEqual(59);
561+
expect(status.reason).toEqual('IP');
554562

555563
await limiter.clear();
556564

src/lib/server/rateLimiter.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,28 @@ export class RateLimiter<Extra = never> {
8787
return await this.store.clear();
8888
}
8989

90+
/**
91+
* Check if a request event is rate limited.
92+
* @param {RequestEvent} event
93+
* @returns {Promise<limited: boolean, reason: 'IP' | 'IPUA' | 'cookie' | number>} Rate limit status for the event.
94+
*/
95+
async check(
96+
event: RequestEvent,
97+
extraData?: Extra
98+
): Promise<
99+
| { limited: false }
100+
| {
101+
limited: true;
102+
reason: 'IP' | 'IPUA' | 'cookie' | number;
103+
}
104+
> {
105+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
106+
const result = await this._isLimited(event, extraData as any);
107+
108+
if (!result.limited) return { limited: false };
109+
return { limited: true, reason: result.reason };
110+
}
111+
90112
/**
91113
* Check if a request event is rate limited.
92114
* @param {RequestEvent} event
@@ -95,7 +117,15 @@ export class RateLimiter<Extra = never> {
95117
protected async _isLimited(
96118
event: RequestEvent,
97119
extraData: Extra
98-
): Promise<{ limited: boolean; hash: string | null; ttl: number }> {
120+
): Promise<
121+
| { limited: false; hash: string | null; ttl: number }
122+
| {
123+
limited: true;
124+
hash: string | null;
125+
ttl: number;
126+
reason: 'IP' | 'IPUA' | 'cookie' | number;
127+
}
128+
> {
99129
let limited: boolean | undefined = undefined;
100130

101131
for (let i = 0; i < this.plugins.length; i++) {
@@ -109,7 +139,12 @@ export class RateLimiter<Extra = never> {
109139
if (status === true)
110140
return { limited: false, hash: null, ttl: rate[1] };
111141
}
112-
return { limited: true, hash: null, ttl: rate[1] };
142+
return {
143+
limited: true,
144+
hash: null,
145+
ttl: rate[1],
146+
reason: this.limitReason(plugin.limiter, i)
147+
};
113148
} else if (id === null) {
114149
if (limited === undefined) limited = true;
115150
continue;
@@ -136,17 +171,44 @@ export class RateLimiter<Extra = never> {
136171
const status = await this.onLimited(event, 'rate');
137172
if (status === true) return { limited: false, hash, ttl: rate[1] };
138173
}
139-
return { limited: true, hash, ttl: rate[1] };
174+
return {
175+
limited: true,
176+
hash,
177+
ttl: rate[1],
178+
reason: this.limitReason(plugin.limiter, i)
179+
};
140180
}
141181
}
142182

183+
if (limited) {
184+
return {
185+
limited: true,
186+
hash: null,
187+
ttl: this.plugins[this.plugins.length - 1].rate[1],
188+
reason: this.limitReason(
189+
this.plugins[this.plugins.length - 1].limiter,
190+
this.plugins.length - 1
191+
)
192+
};
193+
}
194+
143195
return {
144-
limited: limited ?? false,
196+
limited: false,
145197
hash: null,
146198
ttl: this.plugins[this.plugins.length - 1].rate[1]
147199
};
148200
}
149201

202+
protected limitReason(
203+
plugin: RateLimiterPlugin,
204+
index: number
205+
): 'IP' | 'IPUA' | 'cookie' | number {
206+
if (plugin instanceof IPRateLimiter) return 'IP';
207+
if (plugin instanceof IPUserAgentRateLimiter) return 'IPUA';
208+
if (plugin instanceof CookieRateLimiter) return 'cookie';
209+
return index;
210+
}
211+
150212
constructor(options: RateLimiterOptions = {}) {
151213
this.onLimited = options.onLimited;
152214
this.hashFunction = options.hashFunction ?? defaultHashFunction;

src/lib/server/retryAfterRateLimiter.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,19 @@ export class RetryAfterRateLimiter<Extra = never> extends RateLimiter<Extra> {
2929
/**
3030
* Check if a request event is rate limited.
3131
* @param {RequestEvent} event
32-
* @returns {Promise<limited: boolean, retryAfter: number>} Rate limit status for the event.
32+
* @returns {Promise<limited: boolean, retryAfter: number, reason: 'IP' | 'IPUA' | 'cookie' | number>} Rate limit status for the event.
3333
*/
34-
async check(
34+
override async check(
3535
event: RequestEvent,
3636
extraData?: Extra
37-
): Promise<{ limited: boolean; retryAfter: number }> {
37+
): Promise<
38+
| { limited: false; retryAfter: 0 }
39+
| {
40+
limited: true;
41+
retryAfter: number;
42+
reason: 'IP' | 'IPUA' | 'cookie' | number;
43+
}
44+
> {
3845
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3946
const result = await this._isLimited(event, extraData as any);
4047

@@ -43,14 +50,15 @@ export class RetryAfterRateLimiter<Extra = never> extends RateLimiter<Extra> {
4350
if (result.hash === null) {
4451
return {
4552
limited: true,
46-
retryAfter: RetryAfterRateLimiter.toSeconds(result.ttl)
53+
retryAfter: RetryAfterRateLimiter.toSeconds(result.ttl),
54+
reason: result.reason
4755
};
4856
}
4957

5058
const retryAfter = RetryAfterRateLimiter.toSeconds(
5159
(await this.retryAfter.add(result.hash, result.ttl)) - Date.now()
5260
);
5361

54-
return { limited: true, retryAfter };
62+
return { limited: true, retryAfter, reason: result.reason };
5563
}
5664
}

0 commit comments

Comments
 (0)