Back

LLM10:2025 Unbounded Consumption - Prevention

A Layered Defense Model

No single control stops Unbounded Consumption. The attack surface spans several independent dimensions of cost — input size, output size, request rate, concurrency, money, and query volume — and an attacker only needs one of them left open. The goal is defense in depth: bound every dimension, attribute every request to an identity, watch for anomalies, and fail gracefully when limits are hit.

  Request
     |
  [ Auth ]            reject anonymous / attribute to identity + tenant
     |
  [ Input limits ]    cap bytes + tokens; reject oversized prompts early
     |
  [ Rate + quota ]    per-identity req/min, tokens/day, spend/day
     |
  [ Concurrency ]     cap in-flight requests; queue with a bounded depth
     |
  [ Cost budget ]     check remaining budget BEFORE calling the model
     |
  [ Model call ]      enforce max_tokens + timeout on the inference itself
     |
  [ Monitor ]         meter actual tokens/cost; detect extraction patterns
     |
  Response            (or 429 / 402 / 503 with a clear, bounded error)

Enforce these server-side, at a gateway the client cannot bypass. Never rely on a limit that lives only in client code or in a value the client supplies (like a requested max_tokens) — treat every client-provided limit as a ceiling request to be clamped, not obeyed.

Layer 1: Input Controls

Reject oversized work before it reaches the model. Enforce two independent limits: a cheap byte-length check at the edge, and a token-count check once you know the tokenizer. Count the full prompt — system prompt, retrieved RAG context, and conversation history included, not just the latest user message.

MAX_REQUEST_BYTES   = 32 * 1024      # cheap early reject at the HTTP edge
MAX_INPUT_TOKENS    = 4_000          # after tokenization, whole prompt
MAX_HISTORY_TURNS   = 20             # bound conversation growth
MAX_RAG_CHUNKS      = 8              # cap retrieval breadth (top_k)
MAX_RAG_TOKENS      = 3_000          # cap total retrieved context
MAX_MEDIA_ITEMS     = 4              # images/audio per request
MAX_IMAGE_PIXELS    = 4_000_000      # downscale or reject above this

Layer 2: Output Controls

Output tokens are usually the most expensive half of a request, so a server-enforced max_tokens is the single highest-leverage control. Always set it yourself; if the client supplies one, clamp it to your ceiling rather than trusting it.

SERVER_MAX_OUTPUT_TOKENS = 1_024     # hard ceiling regardless of client ask

def clamp_output_tokens(requested: int | None) -> int:
    if requested is None:
        return DEFAULT_OUTPUT_TOKENS          # sensible default, not "unlimited"
    return min(requested, SERVER_MAX_OUTPUT_TOKENS)

# Also enforce:
#   - stop sequences to end generation deterministically
#   - a wall-clock generation timeout (see Layer 4)
#   - for streaming: a max token count that aborts the stream when reached

Layer 3: Rate Limiting & Quotas

Rate limit per identity (user, API key, tenant), not only per IP — IPs are shared, spoofable, and rotate. Combine a short-window request limit with longer-window token and spend quotas, because a request-count limit alone does not bound the cost of each request.

# Two complementary limits, both keyed by identity:
#   1. Request rate   -> smooths bursts        (e.g. 60 req / minute)
#   2. Token quota    -> bounds total work      (e.g. 200k tokens / day)
#   3. Spend quota    -> bounds total money      (e.g. $5 / day, see Layer 5)

import time, redis
r = redis.Redis()

def allow_request(identity: str, limit: int = 60, window: int = 60) -> bool:
    # Sliding-window counter in Redis, atomic via a pipeline.
    now = time.time()
    key = f"rl:{identity}"
    p = r.pipeline()
    p.zremrangebyscore(key, 0, now - window)      # drop entries outside window
    p.zadd(key, {f"{now}": now})
    p.zcard(key)
    p.expire(key, window)
    _, _, count, _ = p.execute()
    return count <= limit

Prefer an algorithm that smooths bursts — a sliding-window or token-bucket limiter — over a fixed-window counter, which allows a double burst at the window boundary. Return 429 Too Many Requests with a Retry-After header so well-behaved clients back off.

Layer 4: Concurrency, Timeouts & Backpressure

Rate limits bound arrivals over time; they do not bound how many requests are in flight at once. Cap concurrency explicitly, both per-identity and globally, and put a hard timeout on every model call so a slow or stuck generation cannot pin a worker forever.

import asyncio

GLOBAL_CONCURRENCY   = 32            # total in-flight model calls
PER_IDENTITY_INFLIGHT = 2           # simultaneous calls per caller
MODEL_TIMEOUT_S      = 30           # wall-clock cap per generation

global_sem = asyncio.Semaphore(GLOBAL_CONCURRENCY)

async def call_model(identity, prompt, per_id_sems):
    sem = per_id_sems.setdefault(identity, asyncio.Semaphore(PER_IDENTITY_INFLIGHT))
    if sem.locked() and sem._value == 0:
        raise TooManyInflight()                  # shed, don't queue forever
    async with sem, global_sem:                  # bounded occupancy
        try:
            return await asyncio.wait_for(
                model.generate(prompt), timeout=MODEL_TIMEOUT_S)
        except asyncio.TimeoutError:
            raise UpstreamTimeout()              # free the slot, return 504

Layer 5: Cost Budgets & Billing Caps

On a metered backend, money is the resource being attacked. Treat a cost budget as a security control: estimate the cost of a request before making it, deduct from a per-tenant budget, and refuse when the budget is exhausted. Back this with a hard billing cap at the provider so a bug in your own accounting cannot run away.

PRICE_IN_PER_1K  = 0.003            # $ per 1k input tokens (example figures)
PRICE_OUT_PER_1K = 0.015           # $ per 1k output tokens
DAILY_BUDGET_USD = 5.00            # per tenant

def estimated_cost(tokens_in: int, max_out: int) -> float:
    return (tokens_in/1000)*PRICE_IN_PER_1K + (max_out/1000)*PRICE_OUT_PER_1K

def reserve_budget(tenant: str, cost: float) -> bool:
    # Atomically deduct the *worst-case* cost before the call; refund the
    # unused portion after, once actual output tokens are known.
    spent = float(r.get(f"spend:{tenant}") or 0)
    if spent + cost > DAILY_BUDGET_USD:
        return False                             # -> 402 Payment Required / 429
    r.incrbyfloat(f"spend:{tenant}", cost)
    return True

Layer 6: Authentication & Attribution

You cannot bound per-identity usage without an identity. Require authentication on every endpoint that reaches a model, and tie rate limits, quotas, and budgets to a durable identity (account or tenant), not to easily-rotated signals like IP or a disposable free key.

Layer 7: Monitoring & Anomaly Detection

Limits stop the obvious; monitoring catches the patient. Meter actual tokens and cost per request (not just request counts) and alert on deviations from each identity's baseline.

Meter and alert on, per identity and in aggregate:
  - requests / minute            (volumetric flooding)
  - input tokens / request        (context stuffing, sponge inputs)
  - output tokens / request       (unbounded generation)
  - concurrent in-flight count    (concurrency abuse)
  - cost / hour and cost / day    (denial of wallet)
  - query diversity & coverage    (systematic sweep = extraction)
  - error / timeout rate          (saturation, cascading failure)

Log every request with: identity, tenant, tokens_in, tokens_out,
estimated_cost, latency, and outcome -- the raw material for detection.

Layer 8: Anti-Extraction Controls

The theft axis needs its own defenses, because a slow, quota-respecting attacker can still harvest a distillation dataset over time. These controls raise the cost and lower the fidelity of extraction, and help you detect and prove it.

Watermarking and log-prob withholding raise the cost of extraction; they do not make it impossible. Treat them as detection and deterrence layered on top of quotas and monitoring, not as a standalone fix.

Layer 9: Graceful Degradation

When limits are hit or capacity is exhausted, fail predictably. A service that sheds load with clear errors stays available for everyone else; one that tries to serve everything collapses for everyone.

Defense Checklist

ControlStops (DoS / DoW / Theft)Where
Input byte + token capDoS, DoWGateway, before model
Server-enforced max_tokensDoS, DoWEvery generation call
Bounded history / RAG / mediaDoS, DoWPrompt assembly
Per-identity rate limitDoS, DoW, TheftGateway
Token & spend quotasDoW, TheftGateway
Concurrency cap + queue limitDoSGateway / worker pool
Timeouts + circuit breakerDoSModel client
Cost budget + billing capDoWApp + provider console
Authentication + attributionDoS, DoW, TheftEvery endpoint
Metering + anomaly alertsDoS, DoW, TheftObservability
Withhold logits / watermark / logTheftResponse layer
Graceful load sheddingDoS, DoWGateway
Bounded agent step/token budgetDoS, DoWOrchestrator

Next Steps