Skip to content

Commit ddaa59a

Browse files
achingbrainmaschad
andauthored
fix: replace rate-limiter (#2356)
[rate-limiter-flexible](https://npmjs.com/package/rate-limiter-flexible) is a CJS module with a single export of all it's various implementations. This defeats tree shaking resulting in the addition of 42KB to the bundle size. animir/node-rate-limiter-flexible#249 This PR brings the source & tests for the in-memory rate limiter into `@libp2p/utils` which reduces the bundle size increase to a few KBs. --------- Co-authored-by: chad <chad.nehemiah94@gmail.com>
1 parent 4691f41 commit ddaa59a

File tree

8 files changed

+571
-9
lines changed

8 files changed

+571
-9
lines changed

packages/libp2p/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@
103103
"merge-options": "^3.0.4",
104104
"multiformats": "^13.0.0",
105105
"private-ip": "^3.0.1",
106-
"rate-limiter-flexible": "^4.0.0",
107106
"uint8arrays": "^5.0.0"
108107
},
109108
"devDependencies": {

packages/libp2p/src/connection-manager/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { CodeError, KEEP_ALIVE } from '@libp2p/interface'
22
import { PeerMap } from '@libp2p/peer-collections'
33
import { defaultAddressSort } from '@libp2p/utils/address-sort'
4+
import { RateLimiter } from '@libp2p/utils/rate-limiter'
45
import { type Multiaddr, type Resolver, multiaddr } from '@multiformats/multiaddr'
56
import { dnsaddrResolver } from '@multiformats/multiaddr/resolvers'
6-
import { RateLimiterMemory } from 'rate-limiter-flexible'
77
import { codes } from '../errors.js'
88
import { getPeerAddress } from '../get-peer.js'
99
import { AutoDial } from './auto-dial.js'
@@ -167,7 +167,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
167167
public readonly dialQueue: DialQueue
168168
public readonly autoDial: AutoDial
169169
public readonly connectionPruner: ConnectionPruner
170-
private readonly inboundConnectionRateLimiter: RateLimiterMemory
170+
private readonly inboundConnectionRateLimiter: RateLimiter
171171

172172
private readonly peerStore: PeerStore
173173
private readonly metrics?: Metrics
@@ -206,7 +206,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
206206
this.maxIncomingPendingConnections = init.maxIncomingPendingConnections ?? defaultOptions.maxIncomingPendingConnections
207207

208208
// controls individual peers trying to dial us too quickly
209-
this.inboundConnectionRateLimiter = new RateLimiterMemory({
209+
this.inboundConnectionRateLimiter = new RateLimiter({
210210
points: init.inboundConnectionThreshold ?? defaultOptions.inboundConnectionThreshold,
211211
duration: 1
212212
})

packages/stream-multiplexer-mplex/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
"it-pipe": "^3.0.1",
6767
"it-pushable": "^3.2.1",
6868
"it-stream-types": "^2.0.1",
69-
"rate-limiter-flexible": "^4.0.0",
7069
"uint8-varint": "^2.0.0",
7170
"uint8arraylist": "^2.4.3",
7271
"uint8arrays": "^5.0.0"

packages/stream-multiplexer-mplex/src/mplex.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { CodeError } from '@libp2p/interface'
22
import { closeSource } from '@libp2p/utils/close-source'
3+
import { RateLimiter } from '@libp2p/utils/rate-limiter'
34
import { pipe } from 'it-pipe'
45
import { type Pushable, pushable } from 'it-pushable'
5-
import { RateLimiterMemory } from 'rate-limiter-flexible'
66
import { toString as uint8ArrayToString } from 'uint8arrays'
77
import { Decoder } from './decode.js'
88
import { encode } from './encode.js'
@@ -59,7 +59,7 @@ export class MplexStreamMuxer implements StreamMuxer {
5959
private readonly _init: MplexStreamMuxerInit
6060
private readonly _source: Pushable<Message>
6161
private readonly closeController: AbortController
62-
private readonly rateLimiter: RateLimiterMemory
62+
private readonly rateLimiter: RateLimiter
6363
private readonly closeTimeout: number
6464
private readonly logger: ComponentLogger
6565

@@ -114,7 +114,7 @@ export class MplexStreamMuxer implements StreamMuxer {
114114
*/
115115
this.closeController = new AbortController()
116116

117-
this.rateLimiter = new RateLimiterMemory({
117+
this.rateLimiter = new RateLimiter({
118118
points: init.disconnectThreshold ?? DISCONNECT_THRESHOLD,
119119
duration: 1
120120
})

packages/utils/package.json

+5
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@
8484
"types": "./dist/src/peer-queue.d.ts",
8585
"import": "./dist/src/peer-queue.js"
8686
},
87+
"./rate-limiter": {
88+
"types": "./dist/src/rate-limiter.d.ts",
89+
"import": "./dist/src/rate-limiter.js"
90+
},
8791
"./stream-to-ma-conn": {
8892
"types": "./dist/src/stream-to-ma-conn.d.ts",
8993
"import": "./dist/src/stream-to-ma-conn.js"
@@ -119,6 +123,7 @@
119123
},
120124
"dependencies": {
121125
"@chainsafe/is-ip": "^2.0.2",
126+
"delay": "^6.0.0",
122127
"@libp2p/interface": "^1.1.1",
123128
"@libp2p/logger": "^4.0.4",
124129
"@multiformats/multiaddr": "^12.1.10",

packages/utils/src/rate-limiter.ts

+287
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { CodeError } from '@libp2p/interface'
2+
import delay from 'delay'
3+
4+
export interface RateLimiterInit {
5+
/**
6+
* Number of points
7+
*
8+
* @default 4
9+
*/
10+
points?: number
11+
12+
/**
13+
* Per seconds
14+
*
15+
* @default 1
16+
*/
17+
duration?: number
18+
19+
/**
20+
* Block if consumed more than points in current duration for blockDuration seconds
21+
*
22+
* @default 0
23+
*/
24+
blockDuration?: number
25+
26+
/**
27+
* Execute allowed actions evenly over duration
28+
*
29+
* @default false
30+
*/
31+
execEvenly?: boolean
32+
33+
/**
34+
* ms, works with execEvenly=true option
35+
*
36+
* @default duration * 1000 / points
37+
*/
38+
execEvenlyMinDelayMs?: number
39+
40+
/**
41+
* @default rlflx
42+
*/
43+
keyPrefix?: string
44+
}
45+
46+
export interface GetKeySecDurationOptions {
47+
customDuration?: number
48+
}
49+
50+
export interface RateLimiterResult {
51+
remainingPoints: number
52+
msBeforeNext: number
53+
consumedPoints: number
54+
isFirstInDuration: boolean
55+
}
56+
57+
export interface RateRecord {
58+
value: number
59+
expiresAt?: Date
60+
timeoutId?: ReturnType<typeof setTimeout>
61+
}
62+
63+
export class RateLimiter {
64+
public readonly memoryStorage: MemoryStorage
65+
protected points: number
66+
protected duration: number
67+
protected blockDuration: number
68+
protected execEvenly: boolean
69+
protected execEvenlyMinDelayMs: number
70+
protected keyPrefix: string
71+
72+
constructor (opts: RateLimiterInit = {}) {
73+
this.points = opts.points ?? 4
74+
this.duration = opts.duration ?? 1
75+
this.blockDuration = opts.blockDuration ?? 0
76+
this.execEvenly = opts.execEvenly ?? false
77+
this.execEvenlyMinDelayMs = opts.execEvenlyMinDelayMs ?? (this.duration * 1000 / this.points)
78+
this.keyPrefix = opts.keyPrefix ?? 'rlflx'
79+
this.memoryStorage = new MemoryStorage()
80+
}
81+
82+
async consume (key: string, pointsToConsume: number = 1, options: GetKeySecDurationOptions = {}): Promise<RateLimiterResult> {
83+
const rlKey = this.getKey(key)
84+
const secDuration = this._getKeySecDuration(options)
85+
let res = this.memoryStorage.incrby(rlKey, pointsToConsume, secDuration)
86+
res.remainingPoints = Math.max(this.points - res.consumedPoints, 0)
87+
88+
if (res.consumedPoints > this.points) {
89+
// Block only first time when consumed more than points
90+
if (this.blockDuration > 0 && res.consumedPoints <= (this.points + pointsToConsume)) {
91+
// Block key
92+
res = this.memoryStorage.set(rlKey, res.consumedPoints, this.blockDuration)
93+
}
94+
95+
throw new CodeError('Rate limit exceeded', 'ERR_RATE_LIMIT_EXCEEDED', res)
96+
} else if (this.execEvenly && res.msBeforeNext > 0 && !res.isFirstInDuration) {
97+
// Execute evenly
98+
let delayMs = Math.ceil(res.msBeforeNext / (res.remainingPoints + 2))
99+
if (delayMs < this.execEvenlyMinDelayMs) {
100+
delayMs = res.consumedPoints * this.execEvenlyMinDelayMs
101+
}
102+
103+
await delay(delayMs)
104+
}
105+
106+
return res
107+
}
108+
109+
penalty (key: string, points: number = 1, options: GetKeySecDurationOptions = {}): RateLimiterResult {
110+
const rlKey = this.getKey(key)
111+
const secDuration = this._getKeySecDuration(options)
112+
const res = this.memoryStorage.incrby(rlKey, points, secDuration)
113+
res.remainingPoints = Math.max(this.points - res.consumedPoints, 0)
114+
115+
return res
116+
}
117+
118+
reward (key: string, points: number = 1, options: GetKeySecDurationOptions = {}): RateLimiterResult {
119+
const rlKey = this.getKey(key)
120+
const secDuration = this._getKeySecDuration(options)
121+
const res = this.memoryStorage.incrby(rlKey, -points, secDuration)
122+
res.remainingPoints = Math.max(this.points - res.consumedPoints, 0)
123+
124+
return res
125+
}
126+
127+
/**
128+
* Block any key for secDuration seconds
129+
*
130+
* @param key
131+
* @param secDuration
132+
*/
133+
block (key: string, secDuration: number): RateLimiterResult {
134+
const msDuration = secDuration * 1000
135+
const initPoints = this.points + 1
136+
137+
this.memoryStorage.set(this.getKey(key), initPoints, secDuration)
138+
139+
return {
140+
remainingPoints: 0,
141+
msBeforeNext: msDuration === 0 ? -1 : msDuration,
142+
consumedPoints: initPoints,
143+
isFirstInDuration: false
144+
}
145+
}
146+
147+
set (key: string, points: number, secDuration: number = 0): RateLimiterResult {
148+
const msDuration = (secDuration >= 0 ? secDuration : this.duration) * 1000
149+
150+
this.memoryStorage.set(this.getKey(key), points, secDuration)
151+
152+
return {
153+
remainingPoints: 0,
154+
msBeforeNext: msDuration === 0 ? -1 : msDuration,
155+
consumedPoints: points,
156+
isFirstInDuration: false
157+
}
158+
}
159+
160+
get (key: string): RateLimiterResult | undefined {
161+
const res = this.memoryStorage.get(this.getKey(key))
162+
163+
if (res != null) {
164+
res.remainingPoints = Math.max(this.points - res.consumedPoints, 0)
165+
}
166+
167+
return res
168+
}
169+
170+
delete (key: string): void {
171+
this.memoryStorage.delete(this.getKey(key))
172+
}
173+
174+
private _getKeySecDuration (options?: GetKeySecDurationOptions): number {
175+
if (options?.customDuration != null && options.customDuration >= 0) {
176+
return options.customDuration
177+
}
178+
179+
return this.duration
180+
}
181+
182+
getKey (key: string): string {
183+
return this.keyPrefix.length > 0 ? `${this.keyPrefix}:${key}` : key
184+
}
185+
186+
parseKey (rlKey: string): string {
187+
return rlKey.substring(this.keyPrefix.length)
188+
}
189+
}
190+
191+
class MemoryStorage {
192+
public readonly storage: Map<string, RateRecord>
193+
194+
constructor () {
195+
this.storage = new Map()
196+
}
197+
198+
incrby (key: string, value: number, durationSec: number): RateLimiterResult {
199+
const existing = this.storage.get(key)
200+
201+
if (existing != null) {
202+
const msBeforeExpires = existing.expiresAt != null
203+
? existing.expiresAt.getTime() - new Date().getTime()
204+
: -1
205+
206+
if (existing.expiresAt == null || msBeforeExpires > 0) {
207+
// Change value
208+
existing.value += value
209+
210+
return {
211+
remainingPoints: 0,
212+
msBeforeNext: msBeforeExpires,
213+
consumedPoints: existing.value,
214+
isFirstInDuration: false
215+
}
216+
}
217+
218+
return this.set(key, value, durationSec)
219+
}
220+
221+
return this.set(key, value, durationSec)
222+
}
223+
224+
set (key: string, value: number, durationSec: number): RateLimiterResult {
225+
const durationMs = durationSec * 1000
226+
const existing = this.storage.get(key)
227+
228+
if (existing != null) {
229+
clearTimeout(existing.timeoutId)
230+
}
231+
232+
const record: RateRecord = {
233+
value,
234+
expiresAt: durationMs > 0 ? new Date(Date.now() + durationMs) : undefined
235+
}
236+
237+
this.storage.set(key, record)
238+
239+
if (durationMs > 0) {
240+
record.timeoutId = setTimeout(() => {
241+
this.storage.delete(key)
242+
}, durationMs)
243+
244+
if (record.timeoutId.unref != null) {
245+
record.timeoutId.unref()
246+
}
247+
}
248+
249+
return {
250+
remainingPoints: 0,
251+
msBeforeNext: durationMs === 0 ? -1 : durationMs,
252+
consumedPoints: record.value,
253+
isFirstInDuration: true
254+
}
255+
}
256+
257+
get (key: string): RateLimiterResult | undefined {
258+
const existing = this.storage.get(key)
259+
260+
if (existing != null) {
261+
const msBeforeExpires = existing.expiresAt != null
262+
? existing.expiresAt.getTime() - new Date().getTime()
263+
: -1
264+
return {
265+
remainingPoints: 0,
266+
msBeforeNext: msBeforeExpires,
267+
consumedPoints: existing.value,
268+
isFirstInDuration: false
269+
}
270+
}
271+
}
272+
273+
delete (key: string): boolean {
274+
const record = this.storage.get(key)
275+
276+
if (record != null) {
277+
if (record.timeoutId != null) {
278+
clearTimeout(record.timeoutId)
279+
}
280+
281+
this.storage.delete(key)
282+
283+
return true
284+
}
285+
return false
286+
}
287+
}

0 commit comments

Comments
 (0)