atproto user agency toolkit for individuals and groups
1import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2import { RateLimiter, DEFAULT_RATE_LIMIT_CONFIG } from "./rate-limiter.js";
3import type { RateLimitRule } from "./rate-limiter.js";
4
5describe("RateLimiter", () => {
6 let limiter: RateLimiter;
7
8 beforeEach(() => {
9 limiter = new RateLimiter();
10 });
11
12 afterEach(() => {
13 limiter.stop();
14 });
15
16 describe("check()", () => {
17 const rule: RateLimitRule = { maxRequests: 5, windowMs: 1000 };
18
19 it("allows requests under the limit", () => {
20 for (let i = 0; i < 5; i++) {
21 const result = limiter.check("test", "ip1", rule);
22 expect(result.allowed).toBe(true);
23 expect(result.remaining).toBe(4 - i);
24 }
25 });
26
27 it("rejects requests at the limit", () => {
28 for (let i = 0; i < 5; i++) {
29 limiter.check("test", "ip1", rule);
30 }
31 const result = limiter.check("test", "ip1", rule);
32 expect(result.allowed).toBe(false);
33 expect(result.remaining).toBe(0);
34 expect(result.retryAfterMs).toBeGreaterThan(0);
35 });
36
37 it("isolates different keys", () => {
38 for (let i = 0; i < 5; i++) {
39 limiter.check("test", "ip1", rule);
40 }
41 const result = limiter.check("test", "ip2", rule);
42 expect(result.allowed).toBe(true);
43 });
44
45 it("isolates different pools", () => {
46 for (let i = 0; i < 5; i++) {
47 limiter.check("pool1", "ip1", rule);
48 }
49 const result = limiter.check("pool2", "ip1", rule);
50 expect(result.allowed).toBe(true);
51 });
52
53 it("resets after window expires", () => {
54 vi.useFakeTimers();
55 try {
56 for (let i = 0; i < 5; i++) {
57 limiter.check("test", "ip1", rule);
58 }
59 expect(limiter.check("test", "ip1", rule).allowed).toBe(false);
60
61 // Advance past 2 full windows so previous count is also cleared
62 vi.advanceTimersByTime(2001);
63
64 const result = limiter.check("test", "ip1", rule);
65 expect(result.allowed).toBe(true);
66 expect(result.remaining).toBe(4);
67 } finally {
68 vi.useRealTimers();
69 }
70 });
71
72 it("uses sliding window weight for gradual recovery", () => {
73 vi.useFakeTimers();
74 try {
75 // Fill up the window
76 for (let i = 0; i < 5; i++) {
77 limiter.check("test", "ip1", rule);
78 }
79 expect(limiter.check("test", "ip1", rule).allowed).toBe(false);
80
81 // Advance to 80% through the next window
82 // Previous window count (5) gets weighted by 0.2 = 1.0 effective
83 // So we should have ~4 remaining
84 vi.advanceTimersByTime(1800);
85
86 const result = limiter.check("test", "ip1", rule);
87 expect(result.allowed).toBe(true);
88 } finally {
89 vi.useRealTimers();
90 }
91 });
92 });
93
94 describe("cleanup", () => {
95 it("removes stale entries", () => {
96 vi.useFakeTimers();
97 try {
98 const rule: RateLimitRule = { maxRequests: 10, windowMs: 1000 };
99 limiter.check("test", "ip1", rule);
100 limiter.startCleanup(100);
101
102 // Advance past 2 minutes (cleanup threshold)
103 vi.advanceTimersByTime(130_000);
104
105 // Entry should be cleaned up, next check starts fresh
106 const result = limiter.check("test", "ip1", rule);
107 expect(result.allowed).toBe(true);
108 expect(result.remaining).toBe(9);
109 } finally {
110 vi.useRealTimers();
111 }
112 });
113 });
114
115 describe("stop()", () => {
116 it("clears all state", () => {
117 const rule: RateLimitRule = { maxRequests: 1, windowMs: 60_000 };
118 limiter.check("test", "ip1", rule);
119 limiter.stop();
120
121 // After stop, state is cleared — new check should succeed
122 const result = limiter.check("test", "ip1", rule);
123 expect(result.allowed).toBe(true);
124 });
125 });
126
127 describe("DEFAULT_RATE_LIMIT_CONFIG", () => {
128 it("has expected pools", () => {
129 expect(DEFAULT_RATE_LIMIT_CONFIG.httpUnauthenticated.read.maxRequests).toBe(300);
130 expect(DEFAULT_RATE_LIMIT_CONFIG.httpUnauthenticated.sync.maxRequests).toBe(30);
131 expect(DEFAULT_RATE_LIMIT_CONFIG.httpUnauthenticated.session.maxRequests).toBe(10);
132 expect(DEFAULT_RATE_LIMIT_CONFIG.httpAuthenticated.write.maxRequests).toBe(200);
133 expect(DEFAULT_RATE_LIMIT_CONFIG.firehosePerIp.maxConnections).toBe(3);
134 });
135 });
136});