forked from StellarSplit/StellarSplit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhook-rate-limit.store.ts
More file actions
113 lines (90 loc) · 3.18 KB
/
Copy pathwebhook-rate-limit.store.ts
File metadata and controls
113 lines (90 loc) · 3.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { getRedisUrl } from '../config/redis.config';
@Injectable()
export class WebhookRateLimitStore implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WebhookRateLimitStore.name);
private redis: Redis | null = null;
private available = false;
// Fallback in-memory map if redis is down
private fallbackMap = new Map<string, { count: number; resetAt: number }>();
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
const url = getRedisUrl(this.configService);
try {
this.redis = new Redis(url, {
lazyConnect: true,
maxRetriesPerRequest: 2,
});
this.redis.on('error', (err: Error) => {
if (this.available) {
this.logger.warn(`Redis connection error: ${err.message}`);
}
this.available = false;
});
this.redis.on('connect', () => {
this.available = true;
this.logger.log('Webhook Rate Limit Redis connected');
});
this.redis.on('close', () => {
this.available = false;
});
await this.redis.connect();
} catch (err: any) {
this.logger.warn(`Webhook Rate Limit Redis unavailable — using fallback. ${err.message}`);
this.available = false;
}
}
async onModuleDestroy() {
if (this.redis) {
await this.redis.quit().catch(() => this.redis?.disconnect());
this.redis = null;
this.available = false;
this.logger.log('Webhook Rate Limit Redis connection closed');
}
}
async checkRateLimit(webhookId: string, maxRequests: number, windowMs: number): Promise<boolean> {
if (!this.redis || !this.available) {
return this.checkFallback(webhookId, maxRequests, windowMs);
}
try {
const key = `webhook_rate_limit:${webhookId}`;
// Atomic increment and expiry
const multi = this.redis.multi();
multi.incr(key);
multi.pttl(key);
const results = await multi.exec();
if (!results) return false;
const [incrError, countVal] = results[0];
const [pttlError, pttlVal] = results[1];
if (incrError) throw incrError;
const count = Number(countVal);
const pttl = Number(pttlVal);
if (count === 1 || pttl === -1) {
// Set expiry if it's a new key or lacks expiry
await this.redis.pexpire(key, windowMs);
}
return count <= maxRequests;
} catch (err: any) {
this.logger.warn(`Redis rate limit failed for ${webhookId}, using fallback: ${err.message}`);
return this.checkFallback(webhookId, maxRequests, windowMs);
}
}
private checkFallback(webhookId: string, maxRequests: number, windowMs: number): boolean {
const now = Date.now();
const limit = this.fallbackMap.get(webhookId);
if (!limit || now > limit.resetAt) {
this.fallbackMap.set(webhookId, {
count: 1,
resetAt: now + windowMs,
});
return true;
}
if (limit.count >= maxRequests) {
return false;
}
limit.count += 1;
return true;
}
}