Skip to content

Commit f90e61e

Browse files
committed
build rate limiter logic
1 parent 602173d commit f90e61e

File tree

3 files changed

+125
-0
lines changed

3 files changed

+125
-0
lines changed

src/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
2424
import mobileWalletRoute from "./routes/mobileWallet.js";
2525
import stripeRoutes from "./routes/stripe.js";
2626
import membershipPlugin from "./routes/membership.js";
27+
import rateLimiterPlugin from "./plugins/rateLimiter.js";
2728

2829
dotenv.config();
2930

@@ -71,6 +72,10 @@ async function init() {
7172
await app.register(fastifyZodValidationPlugin);
7273
await app.register(FastifyAuthProvider);
7374
await app.register(errorHandlerPlugin);
75+
await app.register(rateLimiterPlugin, {
76+
limit: 50,
77+
duration: 20,
78+
});
7479
if (!process.env.RunEnvironment) {
7580
process.env.RunEnvironment = "dev";
7681
}

src/api/plugins/rateLimiter.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import fp from "fastify-plugin";
2+
import {
3+
ConditionalCheckFailedException,
4+
UpdateItemCommand,
5+
} from "@aws-sdk/client-dynamodb";
6+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
7+
import { genericConfig } from "common/config.js";
8+
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify";
9+
10+
interface RateLimiterOptions {
11+
limit?: number;
12+
duration?: number;
13+
}
14+
15+
interface RateLimitParams {
16+
ddbClient: DynamoDBClient;
17+
rateLimitIdentifier: string;
18+
duration: number;
19+
limit: number;
20+
userIdentifier: string;
21+
}
22+
23+
async function isAtLimit({
24+
ddbClient,
25+
rateLimitIdentifier,
26+
duration,
27+
limit,
28+
userIdentifier,
29+
}: RateLimitParams): Promise<{
30+
limited: boolean;
31+
resetTime: number;
32+
used: number;
33+
}> {
34+
const nowInSeconds = Math.floor(Date.now() / 1000);
35+
const timeWindow = Math.floor(nowInSeconds / duration) * duration;
36+
const PK = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindow}`;
37+
38+
try {
39+
const result = await ddbClient.send(
40+
new UpdateItemCommand({
41+
TableName: genericConfig.RateLimiterDynamoTableName,
42+
Key: {
43+
PK: { S: PK },
44+
SK: { S: "counter" },
45+
},
46+
UpdateExpression: "ADD #rateLimitCount :inc SET #ttl = :ttl",
47+
ConditionExpression:
48+
"attribute_not_exists(#rateLimitCount) OR #rateLimitCount <= :limit",
49+
ExpressionAttributeValues: {
50+
":inc": { N: "1" },
51+
":limit": { N: limit.toString() },
52+
":ttl": { N: (timeWindow + duration).toString() },
53+
},
54+
ExpressionAttributeNames: {
55+
"#rateLimitCount": "rateLimitCount",
56+
"#ttl": "ttl",
57+
},
58+
ReturnValues: "UPDATED_NEW",
59+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
60+
}),
61+
);
62+
return {
63+
limited: false,
64+
used: parseInt(result.Attributes?.rateLimitCount.N || "1", 10),
65+
resetTime: timeWindow + duration,
66+
};
67+
} catch (error) {
68+
if (error instanceof ConditionalCheckFailedException) {
69+
return { limited: true, resetTime: timeWindow + duration, used: limit };
70+
}
71+
throw error;
72+
}
73+
}
74+
75+
const rateLimiterPlugin: FastifyPluginAsync<RateLimiterOptions> = async (
76+
fastify,
77+
options,
78+
) => {
79+
const { limit = 10, duration = 60 } = options;
80+
81+
fastify.addHook(
82+
"onRequest",
83+
async (request: FastifyRequest, reply: FastifyReply) => {
84+
const userIdentifier = request.ip;
85+
const rateLimitIdentifier = "api-request";
86+
87+
const { limited, resetTime, used } = await isAtLimit({
88+
ddbClient: fastify.dynamoClient,
89+
rateLimitIdentifier,
90+
duration,
91+
limit,
92+
userIdentifier,
93+
});
94+
reply.header("X-RateLimit-Limit", limit.toString());
95+
reply.header("X-RateLimit-Reset", resetTime?.toString() || "0");
96+
reply.header(
97+
"X-RateLimit-Remaining",
98+
limited ? 0 : used ? limit - used : limit - 1,
99+
);
100+
if (limited) {
101+
const retryAfter = resetTime
102+
? resetTime - Math.floor(Date.now() / 1000)
103+
: undefined;
104+
reply.header("Retry-After", retryAfter?.toString() || "0");
105+
return reply.status(429).send({
106+
error: true,
107+
name: "RateLimitExceededError",
108+
id: 409,
109+
message: "Rate limit exceeded.",
110+
});
111+
}
112+
},
113+
);
114+
};
115+
116+
export default fp(rateLimiterPlugin, {
117+
name: "fastify-rate-limiter",
118+
});

src/common/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ConfigType = {
2121
};
2222

2323
export type GenericConfigType = {
24+
RateLimiterDynamoTableName: string;
2425
EventsDynamoTableName: string;
2526
CacheDynamoTableName: string;
2627
StripeLinksDynamoTableName: string;
@@ -52,6 +53,7 @@ export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507";
5253
export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46";
5354

5455
const genericConfig: GenericConfigType = {
56+
RateLimiterDynamoTableName: "infra-core-api-rate-limiter",
5557
EventsDynamoTableName: "infra-core-api-events",
5658
StripeLinksDynamoTableName: "infra-core-api-stripe-links",
5759
CacheDynamoTableName: "infra-core-api-cache",

0 commit comments

Comments
 (0)