Per-User Quotas in AI Agents: Rate Limiting That Works
You have an agent in production with ten active clients. Nine use it normally. The tenth has been running queries in a loop for two hours from an unattended script someone on their team left running. By Monday morning when you check the Anthropic invoice, that single client has consumed 73% of the monthly spend you projected for everyone.
Nothing is wrong with the agent. There's no bug. The model did exactly what it was supposed to do. The problem is that you built the agent without a quota layer.
Why the provider's rate limits don't protect you
When you read Anthropic's or OpenAI's documentation, you see usage limits: tokens per minute, requests per minute, tokens per day. It's easy to assume those limits are sufficient. They're not.
Provider-level limits operate on your account, not on your user. If a tenant in your product consumes 800,000 tokens in a day, the provider doesn't know — it just sees another request from your API key. You absorb the cost.
What you need is a control layer in your code that answers three questions before every LLM call:
- How many tokens has this tenant consumed in the current window?
- Are they approaching the limit? → warn them
- Have they exceeded it? → block them
That's a quota system. And most agents in production don't have one until the first billing incident.
The architecture: Redis as a distributed counter
The standard pattern uses Redis as the counter store. The reason is straightforward: Redis has atomic increment operations that work correctly in horizontally scaled environments (Vercel Edge, containers, etc.). An in-memory counter in a single process won't protect you if you have multiple server instances.
The counter structure is minimal:
// lib/quota.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
const DAILY_TOKEN_LIMIT = 100_000;
const SOFT_LIMIT_RATIO = 0.8; // warn at 80%
export class QuotaExceededError extends Error {
constructor(public tenantId: string, public used: number) {
super(`Quota exceeded for ${tenantId}: ${used} / ${DAILY_TOKEN_LIMIT}`);
}
}
export async function checkQuota(tenantId: string): Promise<{ used: number; limit: number }> {
const key = `quota:${tenantId}:${getDayKey()}`;
const used = Number(await redis.get(key) ?? 0);
if (used >= DAILY_TOKEN_LIMIT) {
throw new QuotaExceededError(tenantId, used);
}
if (used > DAILY_TOKEN_LIMIT * SOFT_LIMIT_RATIO) {
// Non-blocking — queues async notification
await notifyQuotaWarning(tenantId, used, DAILY_TOKEN_LIMIT);
}
return { used, limit: DAILY_TOKEN_LIMIT };
}
export async function recordTokenUsage(tenantId: string, tokens: number): Promise<void> {
const key = `quota:${tenantId}:${getDayKey()}`;
const pipeline = redis.pipeline();
pipeline.incrby(key, tokens);
pipeline.expire(key, 86400 * 2); // generous TTL for history
await pipeline.exec();
}
function getDayKey(): string {
return new Date().toISOString().slice(0, 10); // "2026-09-18"
}
getDayKey() defines the window as a UTC calendar day. You can switch to a rolling 24-hour window if you need more precision, but a daily reset is easier to communicate to users and sufficient for most use cases.
The agent wrapper: quotas before and after
The integration pattern has two checkpoints: one before the LLM call (checks whether the request can proceed) and one after (records actual consumption):
// lib/agent-with-quota.ts
import Anthropic from '@anthropic-ai/sdk';
import { checkQuota, recordTokenUsage, QuotaExceededError } from './quota';
const anthropic = new Anthropic();
export async function runAgentWithQuota(tenantId: string, userMessage: string) {
// 1. Check quota BEFORE the call
await checkQuota(tenantId);
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }],
});
// 2. Record REAL usage post-call
const tokensUsed = response.usage.input_tokens + response.usage.output_tokens;
await recordTokenUsage(tenantId, tokensUsed);
return response;
}
The pre-call check uses an implicit estimate: if the tenant is already at the limit, the call doesn't happen. If they're below it, the call runs and real usage is recorded afterward. This sequence is correct for the vast majority of agents — estimating tokens before the call adds complexity with little benefit in most scenarios.
Handling the error in the Route Handler
QuotaExceededError should translate to an HTTP 429 response with a message the client can show to the end user:
// app/api/agent/route.ts
import { QuotaExceededError } from '@/lib/quota';
export async function POST(req: Request) {
const { message, tenantId } = await req.json();
try {
const result = await runAgentWithQuota(tenantId, message);
return Response.json({ text: result.content[0] });
} catch (err) {
if (err instanceof QuotaExceededError) {
return Response.json(
{ error: 'You have reached your daily usage limit. Resets at 00:00 UTC.' },
{ status: 429 }
);
}
throw err;
}
}
The error message should be informative for the end user, not a technical log. "You've reached your limit" is far better than "QuotaExceededError: quota:tenant-abc:2026-09-18".
Soft limit: the warning that saves relationships
The hard limit blocks. The soft limit warns — giving tenants time to adjust their usage before hitting the wall.
notifyQuotaWarning can be as simple as a structured log or as complex as an email or dashboard notification. The minimum viable implementation:
async function notifyQuotaWarning(tenantId: string, used: number, limit: number): Promise<void> {
const pct = Math.round((used / limit) * 100);
// Prevent spam: only notify once per window
const warningKey = `quota:warned:${tenantId}:${getDayKey()}`;
const alreadyWarned = await redis.get(warningKey);
if (alreadyWarned) return;
await redis.setex(warningKey, 86400, '1');
// Here: send email, webhook, Slack — whatever your stack uses
console.log(JSON.stringify({
event: 'quota_warning',
tenantId,
used,
limit,
percentage: pct,
timestamp: new Date().toISOString(),
}));
}
The warningKey with TTL prevents sending the alert a hundred times. One notification per day per tenant is enough.
Plan-based quotas: not all users are equal
If you have different pricing tiers, the limit can't be the same for everyone. The cleanest approach is loading the limit from your database based on the tenant's plan:
async function getLimitForTenant(tenantId: string): Promise<number> {
const { plan } = await db.tenants.findUnique({ where: { id: tenantId } });
const limits: Record<string, number> = {
free: 20_000,
pro: 100_000,
enterprise: 500_000,
};
return limits[plan] ?? limits.free;
}
This limit can also be cached in Redis with a short TTL to avoid a database hit on every request.
Real-world impact
On a production AI agent integration project with twelve active tenants, the consumption distribution before implementing quotas was: 15% of tenants generated 68% of total cost. Three tenants. None of them knew they had a misconfigured background process burning tokens.
After adding quotas with a soft limit at 80%:
- All three tenants received a warning before hitting the limit
- Two fixed their integrations on their own
- The third needed to upgrade their plan
- Monthly cost dropped 31% without touching the agent itself
The pattern isn't complex. It's the kind of infrastructure that in AI Driven Development we ship in the first sprint of any LLM product, because the cost of skipping it is always higher than the cost of building it upfront.
If you have an agent in production and you don't know how much each of your users is consuming, that's the signal. Message me on WhatsApp — the first thing we do is audit the cost architecture before touching anything else.