Unbounded Consumption & Monitoring
By Syncfusion®, Inc. · Last updated
A 60,000-token message, a request burst, a spend run-up and a runaway loop. Size per-user limits above real use, check cost before the call, and alert on attack signatures in traces.
Unbounded Consumption and Monitoring
Ben pastes 60,000 tokens of log into the support chat and asks for a summary. Then a script does the same thing, again and again. Replay the first one.
The ledger shows the cost of one message. Nothing refused it, and nothing raised an alert. Multiply by a script running all night, and the bill arrives before anyone notices. No data leaked; the damage is money, capacity and every other customer's slow replies.
TL;DR
- Unbounded consumption (LLM10): floods of tokens, bursts of requests, expensive work on repeat, and loops that never stop.
- Four limits, per user: requests per minute, tokens per message, spend per day, tool calls per request. Each one sized above your real users.
- Limits stop the damage. Monitoring tells you it is happening: alert on attack signatures in your traces.
- Limits that are too tight break honest use. Measure normal traffic first.
- Provider spend limits and an API gateway are your last line, not your first.
- Investigate incidents from the alert to the session timeline to the content's origin. Traces must keep tool arguments.
A Real-World Example
Ama's team ships a free "summarise any PDF" tool. A user uploads the same 800-page PDF a thousand times through a script. Each summary costs a few cents. Overnight the bill is several thousand dollars, and real users get timeouts because the model provider throttles the whole account.
The team had a provider spend limit, set for the whole month, so it never triggered. After the fix: 20 requests a minute per user, a size cap per upload, a daily spend cap per user, and an alert when any user's spend crosses the cap.
What You Will Learn
- The four kinds of unbounded consumption
- How to size rate, token, spend and step limits from real usage
- Why limits that are too tight fail the grade
- What attack signatures look like in traces, and how to alert on them
- Where provider limits and API gateways fit
Cheat Sheet: Limits and Signals
| Attack | Limit | Size it at | Alert when |
|---|---|---|---|
| Token flood | Tokens per message | Above your longest honest message | A message over 30,000 tokens |
| Request burst | Requests per minute per user | Above a busy honest user | A user exceeds the limit |
| Spend run-up | Spend per user per day | Above a heavy honest day | A user hits the cap |
| Runaway loop | Tool calls per request | Above your longest honest task | A request hits the cap |
Four limits sized above real use, and an alarm on each.
Size Limits from Real Traffic
Start from what honest users actually do. Here are numbers from the honest traffic in the range:
Sizing a token cap
const honestTokens = [120, 800, 1500, 3000, 450];
const attackTokens = [60000, 120000, 60000];
for (const cap of [2000, 8000]) {
const broken = honestTokens.filter((t) => t > cap).length;
const stopped = attackTokens.filter((t) => t > cap).length;
console.log("cap " + cap + ": stops " + stopped + " of 3 floods, refuses " + broken + " honest message(s)");
}
Sizing a token cap
var honestTokens = new[] { 120, 800, 1500, 3000, 450 };
var attackTokens = new[] { 60000, 120000, 60000 };
foreach (var cap in new[] { 2000, 8000 })
{
var broken = honestTokens.Count(t => t > cap);
var stopped = attackTokens.Count(t => t > cap);
Console.WriteLine("cap " + cap + ": stops " + stopped + " of 3 floods, refuses " + broken + " honest message(s)");
}
Expected output:
cap 2000: stops 3 of 3 floods, refuses 1 honest message(s)
cap 8000: stops 3 of 3 floods, refuses 0 honest message(s)
A 2,000-token cap stops every flood and refuses the customer who pasted a 3,000-token error log. Try very tight limits on the whole suite:
Every attack stopped. Two honest requests failed: the long error log, and a 5-step task cut off by a 3-step cap. That is over-blocking, and it fails the grade.
Spend Is Per User, Per Day
A monthly limit for the whole account protects the company from bankruptcy, not one user from draining it. Count spend per user:
A per-user daily spend cap
const cap = 5;
const spent: Record<string, number> = {};
function charge(user: string, dollars: number) {
const today = (spent[user] ?? 0) + dollars;
if (today > cap) return user + ": refused, would be $" + today.toFixed(2) + " today (cap $" + cap + ")";
spent[user] = today;
return user + ": ok, $" + today.toFixed(2) + " today";
}
console.log(charge("ana", 0.02));
console.log(charge("ben", 4));
console.log(charge("ben", 3));
console.log(charge("ana", 0.5));
A per-user daily spend cap
const double Cap = 5;
var spent = new Dictionary<string, double>();
string Charge(string user, double dollars)
{
var today = spent.GetValueOrDefault(user) + dollars;
if (today > Cap) return user + ": refused, would be $" + today.ToString("F2") + " today (cap $" + Cap + ")";
spent[user] = today;
return user + ": ok, $" + today.ToString("F2") + " today";
}
Console.WriteLine(Charge("ana", 0.02));
Console.WriteLine(Charge("ben", 4));
Console.WriteLine(Charge("ben", 3));
Console.WriteLine(Charge("ana", 0.5));
Expected output:
ana: ok, $0.02 today
ben: ok, $4.00 today
ben: refused, would be $7.00 today (cap $5)
ana: ok, $0.52 today
Estimate the cost before the call (tokens in, plus a maximum for tokens out), and check it against the cap. Charging after the call means the expensive request already ran.
Watch the Traces
Limits stop attacks you predicted. Traces show you the ones you did not. If you followed the Evals & Observability course, every request already has a trace with token counts, tool calls and cost. Add alerts on the signatures:
Attack signatures in traces
const traces = [
{ user: "ana", tokens: 900, toolCalls: 3, perMinute: 4 },
{ user: "ben", tokens: 60000, toolCalls: 1, perMinute: 2 },
{ user: "ben", tokens: 200, toolCalls: 500, perMinute: 1 },
{ user: "bot-7", tokens: 150, toolCalls: 1, perMinute: 300 },
];
type Trace = { user: string; tokens: number; toolCalls: number; perMinute: number };
const rules: [string, (t: Trace) => boolean][] = [
["huge message", (t) => t.tokens > 30000],
["long loop", (t) => t.toolCalls > 10],
["burst", (t) => t.perMinute > 20],
];
for (const t of traces) {
const hits = rules.filter(([, f]) => f(t)).map(([n]) => n);
console.log(t.user.padEnd(6) + (hits.length ? "ALERT: " + hits.join(", ") : "ok"));
}
Attack signatures in traces
var traces = new[]
{
new Trace("ana", 900, 3, 4),
new Trace("ben", 60000, 1, 2),
new Trace("ben", 200, 500, 1),
new Trace("bot-7", 150, 1, 300),
};
var rules = new (string Name, Func<Trace, bool> Fires)[]
{
("huge message", t => t.Tokens > 30000),
("long loop", t => t.ToolCalls > 10),
("burst", t => t.PerMinute > 20),
};
foreach (var t in traces)
{
var hits = rules.Where(r => r.Fires(t)).Select(r => r.Name).ToList();
Console.WriteLine(t.User.PadRight(6) + (hits.Count > 0 ? "ALERT: " + string.Join(", ", hits) : "ok"));
}
record Trace(string User, int Tokens, int ToolCalls, int PerMinute);
Expected output:
ana ok
ben ALERT: huge message
ben ALERT: long loop
bot-7 ALERT: burst
An alert nobody reads is not monitoring. Send it where someone on call will see it, and include the user, the trace link and which limit fired.
A Loop Is Two Problems
A runaway loop costs money (this lesson) and is an agent control problem (Lesson 8). The step cap from Lesson 8 handles both, so it appears in both suites. Here it is with a cost estimate:
What a loop costs, with and without a cap
const perCall = 0.02;
for (const cap of [0, 10]) {
const calls = cap ? Math.min(cap, 500) : 500;
console.log((cap ? "cap " + cap : "no cap") + ": " + calls + " calls, $" + (calls * perCall).toFixed(2) + " for one request");
}
What a loop costs, with and without a cap
var perCall = 0.02;
foreach (var cap in new[] { 0, 10 })
{
var calls = cap > 0 ? Math.Min(cap, 500) : 500;
Console.WriteLine((cap > 0 ? "cap " + cap : "no cap") + ": " + calls + " calls, $" + (calls * perCall).ToString("F2") + " for one request");
}
Expected output:
no cap: 500 calls, $10.00 for one request
cap 10: 10 calls, $0.20 for one request
Set the provider's own spend limit and an API gateway rate limit too. They are the backstop if your app's limits have a bug. Keep them above your app's limits, so your app's friendly messages fire first.
A rate limit counts each user's requests in a moving window. Here is a limit of 5 a minute:
A sliding-window rate limit
const times = [0, 5, 10, 15, 20, 25, 70];
const limit = 5, windowSec = 60;
const allowed = [];
for (const t of times) { const recent = allowed.filter((a) => t - a < windowSec); if (recent.length < limit) allowed.push(t); console.log("t=" + t + "s " + (recent.length < limit ? "ok" : "refused")); }
A sliding-window rate limit
var times = new[] { 0, 5, 10, 15, 20, 25, 70 };
const int Limit = 5, WindowSec = 60;
var allowed = new List<int>();
foreach (var t in times)
{
var recent = allowed.Count(a => t - a < WindowSec);
if (recent < Limit) allowed.Add(t);
Console.WriteLine("t=" + t + "s " + (recent < Limit ? "ok" : "refused"));
}
Expected output:
t=0s ok
t=5s ok
t=10s ok
t=15s ok
t=20s ok
t=25s refused
t=70s ok
Per-user rate limiting in ASP.NET Core
Investigate an Incident
Alerts tell you that something happened. Traces tell you what, and only if every span carries the session, the user and what was read or sent. The method is the same every time:
- Start from the alert.
- Pull that session's whole timeline.
- Follow what it read back to where that content came from.
From an alert to the entry point
interface Span { t: string; session: string; span: string; detail: string; flag?: "warn" | "alert" }
const trace: Span[] = [
{ t: "13:58:02", session: "s-311", span: "help.publish", detail: "kb-returns edited by guest-77" },
{ t: "13:58:02", session: "s-311", span: "ingest.check", detail: "off", flag: "warn" },
{ t: "14:07:12", session: "s-313", span: "chat.input", detail: "Can I return the desk lamp from A-1001?" },
{ t: "14:07:13", session: "s-313", span: "retrieval", detail: "kb-returns" },
{ t: "14:07:16", session: "s-313", span: "tool.send_email", detail: "to=audit@returns-check.example", flag: "alert" },
{ t: "14:07:17", session: "s-313", span: "model.reply", detail: "Yes, within 30 days." },
];
// 1. Start from the alert. 2. Pull that session's whole timeline. 3. Follow what it read back to where it came from.
const alert = trace.find((s) => s.flag === "alert")!;
const timeline = trace.filter((s) => s.session === alert.session);
for (const s of timeline) console.log(`${s.t} ${s.span.padEnd(16)}${s.detail}`);
const source = timeline.find((s) => s.span === "retrieval")?.detail ?? "";
const origin = trace.find((s) => s.span === "help.publish" && s.detail.startsWith(source));
console.log("entry point: " + (origin ? `${origin.t} ${origin.detail} (session ${origin.session})` : "unknown"));
From an alert to the entry point
var trace = new List<Span>
{
new("13:58:02", "s-311", "help.publish", "kb-returns edited by guest-77"),
new("13:58:02", "s-311", "ingest.check", "off", "warn"),
new("14:07:12", "s-313", "chat.input", "Can I return the desk lamp from A-1001?"),
new("14:07:13", "s-313", "retrieval", "kb-returns"),
new("14:07:16", "s-313", "tool.send_email", "to=audit@returns-check.example", "alert"),
new("14:07:17", "s-313", "model.reply", "Yes, within 30 days."),
};
// 1. Start from the alert. 2. Pull that session's whole timeline. 3. Follow what it read back to where it came from.
var alert = trace.First(s => s.Flag == "alert");
var timeline = trace.Where(s => s.Session == alert.Session).ToList();
foreach (var s in timeline) Console.WriteLine($"{s.T} {s.Name.PadRight(16)}{s.Detail}");
var source = timeline.FirstOrDefault(s => s.Name == "retrieval")?.Detail ?? "";
var origin = trace.FirstOrDefault(s => s.Name == "help.publish" && s.Detail.StartsWith(source));
Console.WriteLine("entry point: " + (origin is not null ? $"{origin.T} {origin.Detail} (session {origin.Session})" : "unknown"));
record Span(string T, string Session, string Name, string Detail, string? Flag = null);
Expected output:
14:07:12 chat.input Can I return the desk lamp from A-1001?
14:07:13 retrieval kb-returns
14:07:16 tool.send_email to=audit@returns-check.example
14:07:17 model.reply Yes, within 30 days.
entry point: 13:58:02 kb-returns edited by guest-77 (session s-311)
Now do it yourself on a full trace. A customer's neighbour got an email quoting her order:
Keep traces for at least as long as your incident response needs (30 to 90 days is common), redact them the same way as logs (Lesson 4), and log every tool call with its arguments. Without the arguments, the send_email above would look like any other email.
In Production
- Rate limiting middleware in ASP.NET Core and API gateways give you per-user rate limits.
- Set the model provider's own spend limit as the backstop, above your app's limits.
- Emit token counts and cost on each trace with the OpenTelemetry GenAI conventions, and alert from your observability tool.
Lab Exercise: Cap Rate, Tokens, Spend and Steps, and Watch the Traces
Pick the Lesson 11 suite. Four new controls appear under "Limits and monitoring". The step cap from Lesson 8 counts too.
Pass rule: every flood, burst, spend run-up and loop capped (12 attacks), each flood, burst and spend run-up raised as an alert in the traces, and at least 98% of honest traffic served, including a 3,000-token error log, a busy user sending 15 requests a minute and a 5-step task.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| One limit for the whole account | "The account has a monthly cap." | Limit per user. | One user uses up everyone's budget. |
| Limits below honest use | "Tight limits are safer." | Measure real traffic first, then set limits above it. | Honest users hit the limit and leave. |
| Charging after the call | "We count the cost once it runs." | Estimate the cost and check the budget before the call. | One huge request blows the budget. |
| Alerts with no owner | "We have alerts set up." | Route each alert to someone who will act. | A spend spike runs all weekend unseen. |
| Relying on the provider's limit | "The provider will stop us." | Set your own product limits. The provider's is a backstop. | Users get blunt errors and the bill is still high. |
Exercises
What to Remember
- Limit rate, message size, spend and steps, per user, above real use.
- Check cost before the call.
- Alert on attack signatures in traces, and route alerts to someone.
- Provider limits and gateways are the backstop.
Further Reading
- LLM10:2025 Unbounded Consumption, OWASP: denial of service, denial of wallet and model extraction.
- OpenTelemetry semantic conventions for generative AI: standard trace attributes for tokens and model calls.
- Denial of Service Cheat Sheet, OWASP: rate limiting and resource limits.
- Semantic Conventions for Generative AI systems, OpenTelemetry: standard span names and attributes for model and tool calls.
Next: Coding-Agent Threat Model. Page 3 starts: your own coding agent, the repo it reads, and what it can run and send.