Skip to content
This repository was archived by the owner on Mar 24, 2023. It is now read-only.

Commit 72dcbd0

Browse files
authored
Merge pull request #597 from godwokenrises/1.10-batch-limit
feat(1.10-rc): check batch request limit and RPC method rate limit
2 parents 96407f9 + 8098f8c commit 72dcbd0

File tree

7 files changed

+166
-22
lines changed

7 files changed

+166
-22
lines changed

.github/workflows/godwoken-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ jobs:
1717
MANUAL_BUILD_WEB3_INDEXER=true
1818
WEB3_GIT_URL=https://github.com/${{ github.repository }}
1919
WEB3_GIT_CHECKOUT=${{ github.ref }}
20+
GODWOKEN_KICKER_REPO=godwokenrises/godwoken-kicker
21+
GODWOKEN_KICKER_REF=1ba9ec08bf940e7222931ccc2940159dc877d1b4

.github/workflows/unit-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- uses: actions/checkout@v3
2525
with:
2626
repository: godwokenrises/godwoken-kicker
27-
ref: 'develop'
27+
ref: '1ba9ec08bf940e7222931ccc2940159dc877d1b4'
2828
- name: Kicker init
2929
run: ./kicker init
3030
- name: Kicker start

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ rate limit config
6060
```bash
6161
$ cat > ./packages/api-server/rate-limit-config.json <<EOF
6262
{
63+
"batch_limit": 1000,
6364
"expired_time_milsec": 60000,
6465
"methods": {
6566
"poly_executeRawL2Transaction": 30,

packages/api-server/src/cache/guard.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ const configPath = path.resolve(__dirname, "../../rate-limit-config.json");
1111

1212
export const EXPIRED_TIME_MILSECS = 1 * 60 * 1000; // milsec, default 1 minutes
1313
export const MAX_REQUEST_COUNT = 30;
14+
export const BATCH_LIMIT = 100000; // 100_000 RPCs in single batch req
1415

1516
export interface RateLimitConfig {
17+
batch_limit: number;
1618
expired_time_milsec: number;
1719
methods: RpcMethodLimit;
1820
}
@@ -39,6 +41,7 @@ export class AccessGuard {
3941
public store: Store;
4042
public rpcMethods: RpcMethodLimit;
4143
public expiredTimeMilsecs: number;
44+
public batchLimit: number;
4245

4346
constructor(
4447
enableExpired = true,
@@ -51,6 +54,7 @@ export class AccessGuard {
5154
this.store = store || new Store(enableExpired, expiredTimeMilsecs);
5255
this.rpcMethods = config.methods;
5356
this.expiredTimeMilsecs = expiredTimeMilsecs || CACHE_EXPIRED_TIME_MILSECS;
57+
this.batchLimit = config.batch_limit || BATCH_LIMIT;
5458
}
5559

5660
async setMaxReqLimit(rpcMethod: string, maxReqCount: number) {
@@ -75,11 +79,15 @@ export class AccessGuard {
7579
}
7680
}
7781

78-
async updateCount(rpcMethod: string, reqId: string) {
82+
async updateCount(rpcMethod: string, reqId: string, offset: number = 1) {
7983
const isExist = await this.isExist(rpcMethod, reqId);
8084
if (isExist === true) {
8185
const id = getId(rpcMethod, reqId);
82-
await this.store.incr(id);
86+
if (offset > 1) {
87+
await this.store.incrBy(id, offset);
88+
} else {
89+
await this.store.incr(id);
90+
}
8391
}
8492
}
8593

@@ -90,13 +98,17 @@ export class AccessGuard {
9098
return true;
9199
}
92100

93-
async isOverRate(rpcMethod: string, reqId: string): Promise<boolean> {
101+
async isOverRate(
102+
rpcMethod: string,
103+
reqId: string,
104+
offset: number = 1
105+
): Promise<boolean> {
94106
const id = getId(rpcMethod, reqId);
95107
const data = await this.store.get(id);
96108
if (data == null) return false;
97109
if (this.rpcMethods[rpcMethod] == null) return false;
98110

99-
const count = +data;
111+
const count = +data + offset;
100112
const maxNumber = this.rpcMethods[rpcMethod];
101113
if (count > maxNumber) {
102114
return true;

packages/api-server/src/cache/store.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export class Store {
5555
return await this.client.incr(key);
5656
}
5757

58+
async incrBy(key: string, offset: number) {
59+
const data = await this.client.get(key);
60+
if (data == null) {
61+
throw new Error("can not update before key exits");
62+
}
63+
if (isNaN(data as any)) {
64+
throw new Error("can not update with NaN value");
65+
}
66+
return await this.client.incrBy(key, offset);
67+
}
68+
5869
async ttl(key: string) {
5970
return await this.client.ttl(key);
6071
}

packages/api-server/src/rate-limit.ts

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,47 @@ import { JSONRPCError } from "jayson";
66

77
export const accessGuard = new AccessGuard();
88

9-
export async function wsApplyRateLimitByIp(req: Request, method: string) {
9+
export async function wsApplyBatchRateLimitByIp(
10+
req: Request,
11+
objs: any[]
12+
): Promise<JSONRPCError[] | undefined> {
13+
const ip = getIp(req);
14+
const methods = Object.keys(accessGuard.rpcMethods);
15+
for (const targetMethod of methods) {
16+
const count = calcMethodCount(objs, targetMethod);
17+
if (count > 0 && ip != null) {
18+
const isExist = await accessGuard.isExist(targetMethod, ip);
19+
if (!isExist) {
20+
await accessGuard.add(targetMethod, ip);
21+
}
22+
23+
const isOverRate = await accessGuard.isOverRate(targetMethod, ip, count);
24+
if (isOverRate) {
25+
const remainSecs = await accessGuard.getKeyTTL(targetMethod, ip);
26+
const message = `Too Many Requests, IP: ${ip}, please wait ${remainSecs}s and retry. RPC method: ${targetMethod}.`;
27+
const error: JSONRPCError = {
28+
code: LIMIT_EXCEEDED,
29+
message: message,
30+
};
31+
32+
logger.debug(
33+
`Rate Limit Exceed, ip: ${ip}, method: ${targetMethod}, ttl: ${remainSecs}s`
34+
);
35+
36+
return new Array(objs.length).fill(error);
37+
} else {
38+
await accessGuard.updateCount(targetMethod, ip, count);
39+
}
40+
}
41+
42+
return undefined;
43+
}
44+
}
45+
46+
export async function wsApplyRateLimitByIp(
47+
req: Request,
48+
method: string
49+
): Promise<JSONRPCError | undefined> {
1050
const ip = getIp(req);
1151
const methods = Object.keys(accessGuard.rpcMethods);
1252
if (methods.includes(method) && ip != null) {
@@ -23,6 +63,11 @@ export async function applyRateLimitByIp(
2363
res: Response,
2464
next: NextFunction
2565
) {
66+
// check batch limit
67+
if (batchLimit(req, res)) {
68+
return;
69+
}
70+
2671
const methods = Object.keys(accessGuard.rpcMethods);
2772
if (methods.length === 0) {
2873
return next();
@@ -45,20 +90,69 @@ export async function applyRateLimitByIp(
4590
}
4691
}
4792

93+
export function batchLimit(req: Request, res: Response) {
94+
let isBan = false;
95+
if (isBatchLimit(req.body)) {
96+
isBan = true;
97+
// if reach batch limit, we reject the whole req with error
98+
const message = `Too Many Batch Requests ${req.body.length}, limit: ${accessGuard.batchLimit}.`;
99+
const error = {
100+
code: LIMIT_EXCEEDED,
101+
message: message,
102+
};
103+
104+
logger.debug(
105+
`Batch Limit Exceed, ${req.body.length}, limit: ${accessGuard.batchLimit}`
106+
);
107+
108+
const content = req.body.map((b: any) => {
109+
return {
110+
jsonrpc: "2.0",
111+
id: b.id,
112+
error: error,
113+
};
114+
});
115+
116+
const httpRateLimitCode = 429;
117+
res.status(httpRateLimitCode).send(content);
118+
}
119+
return isBan;
120+
}
121+
122+
export function wsBatchLimit(body: any): JSONRPCError[] | undefined {
123+
if (isBatchLimit(body)) {
124+
// if reach batch limit, we reject the whole req with error
125+
const message = `Too Many Batch Requests ${body.length}, limit: ${accessGuard.batchLimit}.`;
126+
const error: JSONRPCError = {
127+
code: LIMIT_EXCEEDED,
128+
message: message,
129+
};
130+
131+
logger.debug(
132+
`WS Batch Limit Exceed, ${body.length}, limit: ${accessGuard.batchLimit}`
133+
);
134+
135+
return new Array(body.length).fill(error);
136+
}
137+
138+
return undefined;
139+
}
140+
48141
export async function rateLimit(
49142
req: Request,
50143
res: Response,
51144
rpcMethod: string,
52145
reqId: string | undefined
53146
) {
54147
let isBan = false;
55-
if (hasMethod(req.body, rpcMethod) && reqId != null) {
148+
const count = calcMethodCount(req.body, rpcMethod);
149+
if (count > 0 && reqId != null) {
56150
const isExist = await accessGuard.isExist(rpcMethod, reqId);
57151
if (!isExist) {
58152
await accessGuard.add(rpcMethod, reqId);
59153
}
60154

61-
const isOverRate = await accessGuard.isOverRate(rpcMethod, reqId);
155+
const isOverRate = await accessGuard.isOverRate(rpcMethod, reqId, count);
62156
if (isOverRate) {
63157
isBan = true;
64158

@@ -94,7 +188,7 @@ export async function rateLimit(
94188
};
95189
res.status(httpRateLimitCode).header(httpRateLimitHeader).send(content);
96190
} else {
97-
await accessGuard.updateCount(rpcMethod, reqId);
191+
await accessGuard.updateCount(rpcMethod, reqId, count);
98192
}
99193
}
100194
return isBan;
@@ -120,7 +214,7 @@ export async function wsRateLimit(
120214
};
121215

122216
logger.debug(
123-
`Rate Limit Exceed, ip: ${reqId}, method: ${rpcMethod}, ttl: ${remainSecs}s`
217+
`WS Rate Limit Exceed, ip: ${reqId}, method: ${rpcMethod}, ttl: ${remainSecs}s`
124218
);
125219
return { error, remainSecs };
126220
} else {
@@ -129,12 +223,19 @@ export async function wsRateLimit(
129223
return undefined;
130224
}
131225

132-
export function hasMethod(body: any, name: string) {
226+
export function isBatchLimit(body: any) {
227+
if (Array.isArray(body)) {
228+
return body.length >= accessGuard.batchLimit;
229+
}
230+
return false;
231+
}
232+
233+
export function calcMethodCount(body: any, targetMethod: string): number {
133234
if (Array.isArray(body)) {
134-
return body.map((b) => b.method).includes(name);
235+
return body.filter((b) => b.method === targetMethod).length;
135236
}
136237

137-
return body.method === name;
238+
return body.method === targetMethod ? 1 : 0;
138239
}
139240

140241
export function getIp(req: Request) {

packages/api-server/src/ws/methods.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { Log, LogQueryOption, toApiLog } from "../db/types";
1212
import { filterLogsByAddress, filterLogsByTopics, Query } from "../db";
1313
import { Store } from "../cache/store";
1414
import { CACHE_EXPIRED_TIME_MILSECS } from "../cache/constant";
15-
import { wsApplyRateLimitByIp } from "../rate-limit";
15+
import {
16+
wsApplyBatchRateLimitByIp,
17+
wsApplyRateLimitByIp,
18+
wsBatchLimit,
19+
} from "../rate-limit";
1620
import { gwTxHashToEthTxHash } from "../cache/tx-hash";
1721
import { isInstantFinalityHackMode } from "../util";
1822

@@ -73,16 +77,29 @@ export function wrapper(ws: any, req: any) {
7377
const callback = (err: any, result: any) => {
7478
return { err, result };
7579
};
80+
81+
// check batch limit
82+
const errs = wsBatchLimit(objs);
83+
if (errs != null) {
84+
return cb(
85+
errs.map((err) => {
86+
return { err };
87+
})
88+
);
89+
}
90+
91+
// check batch rate limit
92+
const batchErrs = await wsApplyBatchRateLimitByIp(req, objs);
93+
if (batchErrs != null) {
94+
return cb(
95+
batchErrs.map((err) => {
96+
return { err };
97+
})
98+
);
99+
}
100+
76101
const info = await Promise.all(
77102
objs.map(async (obj) => {
78-
// check rate limit
79-
const err = await wsApplyRateLimitByIp(req, obj.method);
80-
if (err != null) {
81-
return {
82-
err,
83-
};
84-
}
85-
86103
if (obj.method === "eth_subscribe") {
87104
const r = ethSubscribe(obj.params, callback);
88105
return r;

0 commit comments

Comments
 (0)