<html lang="en">
<head></head>
<body>

<form id="mainForm" method="post" action="https://stackblitz.com/run" target="_self">
<input type="hidden" name="project[files][README.md]" value="# Rate Limiter for RivetKit

Example project demonstrating API rate limiting with [RivetKit](https://rivetkit.org).

[Learn More →](https://github.com/rivet-dev/rivetkit)

[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues)

## Getting Started

### Prerequisites

- Node.js 18+

### Installation

```sh
git clone https://github.com/rivet-dev/rivetkit
cd rivetkit/examples/rate
npm install
```

### Development

```sh
npm run dev
```

Open your browser to `http://localhost:3000`

## Features

- **Per-User Rate Limiting**: Each user/client gets independent rate limits
- **Sliding Window**: 5 requests per 60-second window
- **Real-time Status**: Live updates of remaining requests and reset time
- **Visual Progress**: Progress bar showing rate limit usage
- **Multiple Users**: Switch between users to test isolation
- **Admin Reset**: Reset rate limits for testing purposes

## How it works

This rate limiter demonstrates:

1. **Per-Actor Rate Limiting**: Each user gets their own actor instance with independent counters
2. **Time Window Management**: Automatic reset of counters when the time window expires
3. **Request Counting**: Track and limit the number of requests within the window
4. **Graceful Degradation**: Blocks requests when limits are exceeded
5. **Status Reporting**: Provide detailed information about current limits and reset times

## Architecture

- **Backend**: RivetKit actor that maintains rate limit state per user
- **Frontend**: React application with real-time rate limit status
- **State Management**: Persistent rate limit counters with automatic window resets
- **User Isolation**: Each user/API client gets independent rate limiting

## Usage

1. Start the development server
2. Select a user from the dropdown
3. Click &quot;Make API Request&quot; to test the rate limiter
4. Watch the status update in real-time
5. Try making more than 5 requests within a minute to see blocking
6. Switch users to see independent rate limits
7. Use &quot;Reset Rate Limiter&quot; to clear limits for testing

## Rate Limiting Strategy

This example uses a **Fixed Window** approach:

- **Window Size**: 60 seconds
- **Request Limit**: 5 requests per window
- **Reset Behavior**: Counter resets to 0 when window expires
- **Granularity**: Per-user/client isolation

## Extending

This rate limiter can be extended with:

- Different rate limiting algorithms (sliding window, token bucket, etc.)
- Multiple rate limit tiers (basic/premium users)
- Geographic or IP-based limiting
- Dynamic rate limits based on user behavior
- Rate limit bypass for admin users
- Metrics and monitoring integration

## License

Apache 2.0
">
<input type="hidden" name="project[files][package.json]" value="{&quot;name&quot;:&quot;example-rate&quot;,&quot;version&quot;:&quot;2.0.21&quot;,&quot;private&quot;:true,&quot;type&quot;:&quot;module&quot;,&quot;scripts&quot;:{&quot;dev&quot;:&quot;concurrently \&quot;npm run dev:backend\&quot; \&quot;npm run dev:frontend\&quot;&quot;,&quot;dev:backend&quot;:&quot;tsx --watch src/backend/server.ts&quot;,&quot;dev:frontend&quot;:&quot;vite&quot;,&quot;build&quot;:&quot;vite build&quot;,&quot;check-types&quot;:&quot;tsc --noEmit&quot;,&quot;test&quot;:&quot;vitest run&quot;},&quot;devDependencies&quot;:{&quot;@types/node&quot;:&quot;^22.13.9&quot;,&quot;@types/react&quot;:&quot;^18.2.0&quot;,&quot;@types/react-dom&quot;:&quot;^18.2.0&quot;,&quot;@vitejs/plugin-react&quot;:&quot;^4.2.0&quot;,&quot;concurrently&quot;:&quot;^8.2.2&quot;,&quot;rivetkit&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/rivetkit@af583ac8b43832d9ab014542396c3c30dbf63767&quot;,&quot;tsx&quot;:&quot;^3.12.7&quot;,&quot;typescript&quot;:&quot;^5.5.2&quot;,&quot;vite&quot;:&quot;^5.0.0&quot;,&quot;vitest&quot;:&quot;^3.1.1&quot;},&quot;dependencies&quot;:{&quot;@rivetkit/react&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/@rivetkit/react@af583ac8b43832d9ab014542396c3c30dbf63767&quot;,&quot;react&quot;:&quot;^18.2.0&quot;,&quot;react-dom&quot;:&quot;^18.2.0&quot;},&quot;stableVersion&quot;:&quot;0.8.0&quot;}">
<input type="hidden" name="project[files][tsconfig.json]" value="{
	&quot;compilerOptions&quot;: {
		&quot;target&quot;: &quot;esnext&quot;,
		&quot;lib&quot;: [&quot;esnext&quot;, &quot;dom&quot;],
		&quot;jsx&quot;: &quot;react-jsx&quot;,
		&quot;module&quot;: &quot;esnext&quot;,
		&quot;moduleResolution&quot;: &quot;bundler&quot;,
		&quot;types&quot;: [&quot;node&quot;, &quot;vite/client&quot;],
		&quot;resolveJsonModule&quot;: true,
		&quot;allowJs&quot;: true,
		&quot;checkJs&quot;: false,
		&quot;noEmit&quot;: true,
		&quot;isolatedModules&quot;: true,
		&quot;allowSyntheticDefaultImports&quot;: true,
		&quot;forceConsistentCasingInFileNames&quot;: true,
		&quot;strict&quot;: true,
		&quot;skipLibCheck&quot;: true
	},
	&quot;include&quot;: [&quot;src/**/*&quot;],
	&quot;exclude&quot;: [&quot;node_modules&quot;, &quot;dist&quot;]
}
">
<input type="hidden" name="project[files][turbo.json]" value="{
	&quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
	&quot;extends&quot;: [&quot;//&quot;]
}
">
<input type="hidden" name="project[files][vite.config.ts]" value="import react from &quot;@vitejs/plugin-react&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
	plugins: [react()],
	root: &quot;src/frontend&quot;,
	server: {
		port: 3000,
	},
});
">
<input type="hidden" name="project[files][vitest.config.ts]" value="import { defineConfig } from &quot;vitest/config&quot;;

export default defineConfig({
	test: {
		include: [&quot;tests/**/*.test.ts&quot;],
	},
});
">
<input type="hidden" name="project[files][tests/rate.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { expect, test, vi } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry&quot;;

test(&quot;Rate limiter allows requests under limit&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const limiter = client.rateLimiter.getOrCreate([&quot;test-limit&quot;]);

	// Test first request - should be allowed
	const result1 = await limiter.checkLimit();
	expect(result1).toMatchObject({
		allowed: true,
		remaining: 4, // 5 total - 1 used = 4 remaining
		resetsIn: expect.any(Number),
	});

	// Test additional requests
	const result2 = await limiter.checkLimit();
	expect(result2.allowed).toBe(true);
	expect(result2.remaining).toBe(3);

	const result3 = await limiter.checkLimit();
	expect(result3.allowed).toBe(true);
	expect(result3.remaining).toBe(2);
});

test(&quot;Rate limiter blocks requests over limit&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const limiter = client.rateLimiter.getOrCreate([&quot;test-block&quot;]);

	// Use up all 5 requests
	for (let i = 0; i &lt; 5; i++) {
		const result = await limiter.checkLimit();
		expect(result.allowed).toBe(true);
	}

	// 6th request should be blocked
	const blocked = await limiter.checkLimit();
	expect(blocked.allowed).toBe(false);
	expect(blocked.remaining).toBe(0);
	expect(blocked.resetsIn).toBeGreaterThan(0);

	// 7th request should also be blocked
	const blocked2 = await limiter.checkLimit();
	expect(blocked2.allowed).toBe(false);
	expect(blocked2.remaining).toBe(0);
});

test(&quot;Rate limiter status reflects current state&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const limiter = client.rateLimiter.getOrCreate([&quot;test-status&quot;]);

	// Initial status
	const initial = await limiter.getStatus();
	expect(initial).toMatchObject({
		count: 0,
		remaining: 5,
		resetsIn: 0, // No reset time set yet
	});

	// After some requests
	await limiter.checkLimit();
	await limiter.checkLimit();

	const afterRequests = await limiter.getStatus();
	expect(afterRequests.count).toBe(2);
	expect(afterRequests.remaining).toBe(3);
	expect(afterRequests.resetsIn).toBeGreaterThan(0);
});

test(&quot;Rate limiter reset functionality&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const limiter = client.rateLimiter.getOrCreate([&quot;test-reset&quot;]);

	// Use up some requests
	await limiter.checkLimit();
	await limiter.checkLimit();
	await limiter.checkLimit();

	const beforeReset = await limiter.getStatus();
	expect(beforeReset.count).toBe(3);
	expect(beforeReset.remaining).toBe(2);

	// Reset the limiter
	const resetResult = await limiter.reset();
	expect(resetResult.success).toBe(true);

	// Check status after reset
	const afterReset = await limiter.getStatus();
	expect(afterReset.count).toBe(0);
	expect(afterReset.remaining).toBe(5);
	expect(afterReset.resetsIn).toBe(0);

	// Should be able to make requests again
	const newRequest = await limiter.checkLimit();
	expect(newRequest.allowed).toBe(true);
	expect(newRequest.remaining).toBe(4);
});
">
<input type="hidden" name="project[files][src/backend/registry.ts]" value="import { actor, setup } from &quot;rivetkit&quot;;

export type RateLimitResult = {
	allowed: boolean;
	remaining: number;
	resetsIn: number;
};

export const rateLimiter = actor({
	// Persistent state that survives restarts: https://rivet.dev/docs/actors/state
	state: {
		count: 0,
		resetAt: 0,
	},

	actions: {
		// Callable functions from clients: https://rivet.dev/docs/actors/actions
		checkLimit: (c): RateLimitResult =&gt; {
			const now = Date.now();

			// Reset if expired
			if (now &gt; c.state.resetAt) {
				// State changes are automatically persisted
				c.state.count = 0;
				c.state.resetAt = now + 60000; // 1 minute window
			}

			const allowed = c.state.count &lt; 5;

			// Increment if allowed
			if (allowed) {
				c.state.count++;
			}

			return {
				allowed,
				remaining: Math.max(0, 5 - c.state.count),
				resetsIn: Math.max(
					0,
					Math.round((c.state.resetAt - now) / 1000),
				),
			};
		},

		getStatus: (c) =&gt; ({
			count: c.state.count,
			resetAt: c.state.resetAt,
			remaining: Math.max(0, 5 - c.state.count),
			resetsIn: Math.max(
				0,
				Math.round((c.state.resetAt - Date.now()) / 1000),
			),
		}),

		reset: (c) =&gt; {
			c.state.count = 0;
			c.state.resetAt = 0;
			return { success: true };
		},
	},
});

// Register actors for use: https://rivet.dev/docs/setup
export const registry = setup({
	use: { rateLimiter },
});
">
<input type="hidden" name="project[files][src/backend/server.ts]" value="import { registry } from &quot;./registry&quot;;

registry.start();
">
<input type="hidden" name="project[files][src/frontend/App.tsx]" value="import { createRivetKit } from &quot;@rivetkit/react&quot;;
import { useEffect, useState } from &quot;react&quot;;
import type { RateLimitResult, registry } from &quot;../backend/registry&quot;;

const { useActor } = createRivetKit&lt;typeof registry&gt;(&quot;http://localhost:8080&quot;);

function RateLimiterDemo({ userId }: { userId: string }) {
	const [result, setResult] = useState&lt;RateLimitResult | null&gt;(null);
	const [loading, setLoading] = useState(false);

	const rateLimiter = useActor({
		name: &quot;rateLimiter&quot;,
		key: [userId],
	});

	useEffect(() =&gt; {
		if (rateLimiter.connection) {
			// Get initial status
			rateLimiter.connection.getStatus().then((status) =&gt; {
				setResult({
					allowed: status.remaining &gt; 0,
					remaining: status.remaining,
					resetsIn: status.resetsIn,
				});
			});
		}
	}, [rateLimiter.connection]);

	const makeRequest = async () =&gt; {
		if (!rateLimiter.connection || loading) return;

		setLoading(true);
		try {
			const response = await rateLimiter.connection.checkLimit();
			setResult(response);
		} finally {
			setLoading(false);
		}
	};

	const resetLimiter = async () =&gt; {
		if (!rateLimiter.connection) return;

		await rateLimiter.connection.reset();
		// Get updated status
		const status = await rateLimiter.connection.getStatus();
		setResult({
			allowed: status.remaining &gt; 0,
			remaining: status.remaining,
			resetsIn: status.resetsIn,
		});
	};

	const usagePercentage = result ? ((5 - result.remaining) / 5) * 100 : 0;

	return (
		&lt;div className=&quot;rate-limiter-demo&quot;&gt;
			&lt;button
				className=&quot;request-button&quot;
				onClick={makeRequest}
				disabled={!rateLimiter.connection || loading}
			&gt;
				{loading ? &quot;Making Request...&quot; : &quot;Make API Request&quot;}
			&lt;/button&gt;

			{result &amp;&amp; (
				&lt;div className=&quot;status-display&quot;&gt;
					&lt;div className=&quot;status-item&quot;&gt;
						&lt;span className=&quot;status-label&quot;&gt;Status:&lt;/span&gt;
						&lt;span className={`status-value ${result.allowed ? &#39;allowed&#39; : &#39;blocked&#39;}`}&gt;
							{result.allowed ? &quot;✓ Request Allowed&quot; : &quot;✖ Request Blocked&quot;}
						&lt;/span&gt;
					&lt;/div&gt;
					&lt;div className=&quot;status-item&quot;&gt;
						&lt;span className=&quot;status-label&quot;&gt;Remaining Requests:&lt;/span&gt;
						&lt;span className=&quot;status-value&quot;&gt;{result.remaining} / 5&lt;/span&gt;
					&lt;/div&gt;
					&lt;div className=&quot;status-item&quot;&gt;
						&lt;span className=&quot;status-label&quot;&gt;Rate Limit Usage:&lt;/span&gt;
						&lt;div style={{ flex: 1, marginLeft: &quot;20px&quot; }}&gt;
							&lt;div className=&quot;progress-bar&quot;&gt;
								&lt;div 
									className=&quot;progress-fill&quot; 
									style={{ width: `${usagePercentage}%` }}
								/&gt;
							&lt;/div&gt;
						&lt;/div&gt;
					&lt;/div&gt;
					&lt;div className=&quot;status-item&quot;&gt;
						&lt;span className=&quot;status-label&quot;&gt;Resets In:&lt;/span&gt;
						&lt;span className=&quot;status-value&quot;&gt;{result.resetsIn} seconds&lt;/span&gt;
					&lt;/div&gt;
				&lt;/div&gt;
			)}

			&lt;button className=&quot;reset-button&quot; onClick={resetLimiter}&gt;
				Reset Rate Limiter (Admin)
			&lt;/button&gt;
		&lt;/div&gt;
	);
}

export function App() {
	const [selectedUser, setSelectedUser] = useState(&quot;user-1&quot;);

	const users = [
		{ id: &quot;user-1&quot;, name: &quot;User 1&quot; },
		{ id: &quot;user-2&quot;, name: &quot;User 2&quot; },
		{ id: &quot;user-3&quot;, name: &quot;User 3&quot; },
		{ id: &quot;api-client-1&quot;, name: &quot;API Client 1&quot; },
		{ id: &quot;api-client-2&quot;, name: &quot;API Client 2&quot; },
	];

	return (
		&lt;div className=&quot;app-container&quot;&gt;
			&lt;div className=&quot;header&quot;&gt;
				&lt;h1&gt;Rate Limiter Demo&lt;/h1&gt;
				&lt;p&gt;5 requests per minute per user/client&lt;/p&gt;
			&lt;/div&gt;

			&lt;div className=&quot;content&quot;&gt;
				&lt;div className=&quot;info-box&quot;&gt;
					&lt;h3&gt;How it works&lt;/h3&gt;
					&lt;p&gt;
						This rate limiter allows 5 requests per minute per user. Each user gets their own 
						independent rate limit counter. When the limit is exceeded, further requests are 
						blocked until the window resets. Switch between users to see isolated rate limiting.
					&lt;/p&gt;
				&lt;/div&gt;

				&lt;div className=&quot;user-selector&quot;&gt;
					&lt;label&gt;Select User/Client:&lt;/label&gt;
					&lt;select
						value={selectedUser}
						onChange={(e) =&gt; setSelectedUser(e.target.value)}
					&gt;
						{users.map((user) =&gt; (
							&lt;option key={user.id} value={user.id}&gt;
								{user.name}
							&lt;/option&gt;
						))}
					&lt;/select&gt;
				&lt;/div&gt;

				&lt;RateLimiterDemo key={selectedUser} userId={selectedUser} /&gt;
			&lt;/div&gt;
		&lt;/div&gt;
	);
}
">
<input type="hidden" name="project[files][src/frontend/index.html]" value="&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Rate Limiter Example&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .app-container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        .header {
            background: #dc3545;
            color: white;
            padding: 20px;
            text-align: center;
        }
        .header h1 {
            margin: 0;
            font-size: 2em;
        }
        .header p {
            margin: 10px 0 0 0;
            opacity: 0.9;
        }
        .content {
            padding: 30px;
        }
        .rate-limiter-demo {
            text-align: center;
        }
        .user-selector {
            margin-bottom: 30px;
            padding: 20px;
            background: #f8f9fa;
            border-radius: 8px;
        }
        .user-selector label {
            display: block;
            margin-bottom: 10px;
            font-weight: 500;
        }
        .user-selector select {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        .request-button {
            background: #007bff;
            color: white;
            border: none;
            padding: 15px 30px;
            font-size: 18px;
            border-radius: 8px;
            cursor: pointer;
            margin-bottom: 30px;
            transition: background-color 0.2s;
        }
        .request-button:hover:not(:disabled) {
            background: #0056b3;
        }
        .request-button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .status-display {
            background: #f8f9fa;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
        }
        .status-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px 0;
            border-bottom: 1px solid #dee2e6;
        }
        .status-item:last-child {
            border-bottom: none;
        }
        .status-label {
            font-weight: 500;
            color: #495057;
        }
        .status-value {
            font-size: 1.1em;
            font-weight: bold;
        }
        .status-value.allowed {
            color: #28a745;
        }
        .status-value.blocked {
            color: #dc3545;
        }
        .info-box {
            background: #e3f2fd;
            border: 1px solid #2196f3;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
        }
        .info-box h3 {
            margin: 0 0 10px 0;
            color: #1976d2;
        }
        .info-box p {
            margin: 0;
            color: #333;
            line-height: 1.5;
        }
        .reset-button {
            background: #6c757d;
            color: white;
            border: none;
            padding: 10px 20px;
            font-size: 14px;
            border-radius: 6px;
            cursor: pointer;
            margin-top: 15px;
        }
        .reset-button:hover {
            background: #5a6268;
        }
        .progress-bar {
            width: 100%;
            height: 20px;
            background: #e9ecef;
            border-radius: 10px;
            overflow: hidden;
            margin: 10px 0;
        }
        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #28a745, #ffc107, #dc3545);
            transition: width 0.3s ease;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
    &lt;script type=&quot;module&quot; src=&quot;/main.tsx&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;">
<input type="hidden" name="project[files][src/frontend/main.tsx]" value="import { StrictMode } from &quot;react&quot;;
import { createRoot } from &quot;react-dom/client&quot;;
import { App } from &quot;./App&quot;;

const root = document.getElementById(&quot;root&quot;);
if (!root) throw new Error(&quot;Root element not found&quot;);

createRoot(root).render(
	&lt;StrictMode&gt;
		&lt;App /&gt;
	&lt;/StrictMode&gt;
);">
<input type="hidden" name="project[description]" value="generated by https://pkg.pr.new">
<input type="hidden" name="project[template]" value="node">
<input type="hidden" name="project[title]" value="example-rate">
</form>
<script>document.getElementById("mainForm").submit();</script>

</body></html>