TL;DR: A runaway agent loop shows up in your API traffic before it shows up on your bill. It looks like the same call repeated fast, or a context that keeps doubling. Tag every LLM call with the product feature it belongs to. Watch the last few calls per feature. When they look like a loop, throttle that one feature first and block it if the loop continues. This post has a small TypeScript circuit breaker you can copy, the limits of doing it yourself, and how the existing tools (LiteLLM, Helicone, Portkey, Langfuse and AtlasBurn) handle it.

Disclosure: I build AtlasBurn, one of the tools compared below. The DIY code works without it.

Why provider spend limits don't save you

OpenAI and Anthropic let you set a monthly budget or alert on the whole account. That's a last line of defence, not loop protection:

  • It's account-wide. When it trips, every AI feature in your app stops, including the ones that were fine.
  • It's slow. Usage dashboards update with a delay. An agent making several calls a second can do real damage before a limit fires.
  • It doesn't know what a loop is. A busy, healthy day and a stuck agent look the same to a budget.

What you want is a per-feature circuit breaker: something that sees which feature is misbehaving and stops only that feature, while the rest of the app keeps running.

What a runaway loop looks like in the traffic

Two patterns cover most incidents:

  1. Retry storm / identical calls. The same model gets the same-sized input several times in a few seconds. Usually a tool call that keeps failing and an agent that keeps retrying.
  2. Runaway context growth. Each call carries the previous output back in, so input tokens climb steadily (often doubling) across a burst of calls. The cost per call grows with it.

A third signal, a request-rate burst, catches fan-out bugs: a job that suddenly sends many requests per second.

The catch is false positives. A normal multi-turn chat also grows its context every turn. So you need two filters: speed (loops are fast, e.g. 4 calls inside 30 seconds) and size (ignore growth until the context is large enough to cost real money, e.g. 4,000+ input tokens).

A per-feature circuit breaker in ~40 lines (TypeScript)

This keeps the last few calls per feature in memory and pauses a feature when its recent calls look like a loop.

type Call = { at: number; model: string; inputTokens: number }; const WINDOW = 4; // look at the last 4 calls of a feature const MAX_SPAN_MS = 30_000; // ...that happened within 30 seconds const MIN_TOKENS = 4_000; // ignore context growth below this size const PAUSE_MS = 10 * 60_000; const recent = new Map<string, Call[]>(); const pausedUntil = new Map<string, number>(); export function isPaused(feature: string): boolean { return (pausedUntil.get(feature) ?? 0) > Date.now(); } export function recordCall(feature: string, call: Call): void { const calls = [...(recent.get(feature) ?? []), call].slice(-WINDOW); recent.set(feature, calls); if (calls.length < WINDOW) return; if (calls[WINDOW - 1].at - calls[0].at > MAX_SPAN_MS) return; // not loop-speed const sameModel = calls.every((c) => c.model === calls[0].model); const tokens = calls.map((c) => c.inputTokens); const identical = sameModel && tokens.every((t) => t === tokens[0]) && tokens[0] > 0; const growing = sameModel && tokens.every((t, i) => i === 0 || t > tokens[i - 1]) && tokens[0] > 0 && tokens[WINDOW - 1] / tokens[0] >= 2 && tokens[WINDOW - 1] >= MIN_TOKENS; if (identical || growing) pausedUntil.set(feature, Date.now() + PAUSE_MS); } 

Wrap your LLM calls with it:

import OpenAI from "openai"; import { isPaused, recordCall } from "./loop-breaker"; const openai = new OpenAI(); export async function chat(feature: string, params: OpenAI.ChatCompletionCreateParamsNonStreaming) { if (isPaused(feature)) { // Fail gracefully for this feature only. Everything else keeps working. throw new Error(`AI feature "${feature}" is paused: loop detected`); } const res = await openai.chat.completions.create(params); recordCall(feature, { at: Date.now(), model: params.model, inputTokens: res.usage?.prompt_tokens ?? 0 }); return res; } await chat("support-chat", { model: "gpt-4o-mini", messages: [{ role: "user", content: "Hi" }] }); 

That's enough to stop the classic "agent retried a failing tool 400 times overnight" incident in a single process.

Where the DIY version falls short

  • One process only. With several instances or serverless functions, each one sees a fraction of the calls. You need shared state such as Redis.
  • It pauses immediately. Real systems want a ladder: throttle first (space calls out), then block only if the pattern keeps going or crosses a dollar amount. Otherwise one false positive takes a feature down.
  • No budgets. It doesn't know what a call costs, so it can't stop a feature that is expensive but not looping.
  • Streaming. With streaming responses you only get token usage if you ask for it (stream_options: { include_usage: true } on OpenAI) and read the final chunk.
  • Every provider and SDK. You have to wrap each client (OpenAI, Anthropic, Gemini…) and each code path that calls them.

How existing tools handle it

From each project's public docs as of November 2026. Features move fast, so check their docs before deciding.

Tool Cost per feature Stops spend automatically Loop / retry-storm detection How it's added
LiteLLM (proxy) By key, team, user or tag Yes: hard budgets and rate limits per key, team, user or tag (needs a Postgres database) Agent session caps (max iterations, budget per session) for agents on its Agent Gateway; no repeated-call or context-growth detector Self-hosted or managed gateway
Helicone Via custom properties (a Helicone-Property-* header) Rate limits, including cost-based ones per user or property (returns 429) Not a dedicated loop detector Gateway / base-URL change
Portkey Via metadata on requests Budget and rate limits on keys (Enterprise and select Pro plans) Not a dedicated loop detector Gateway / base-URL change
Langfuse Via tags and traces No blocking; cost-threshold alerts (Slack, webhook…) No SDK / OpenTelemetry tracing
AtlasBurn Per feature via a request header Yes: per-feature throttle and kill switch, plus daily/hourly budgets Yes: identical-call loops, runaway context growth, request-rate bursts Node.js SDK that patches fetch, or a base-URL proxy for any language

Rule of thumb: if you already run a gateway and mostly need budgets (or a hard cap on agent iterations), LiteLLM or Portkey cover it. If you need tracing and evals, Langfuse. If the specific worry is an agent looping in one feature and you want only that feature stopped, that's the case AtlasBurn is built for.

The same breaker without writing it: AtlasBurn

AtlasBurn runs the checks above server-side for every feature, across instances:

  • Identical-call loops: the last 4 calls of a feature use the same model with the same input size, within 30 seconds.
  • Runaway context growth: input tokens rise on every one of the last 4 calls (same model, within 30 seconds), at least double across them, and the latest call is 4,000+ tokens. That last condition keeps normal chats from tripping it.
  • Request-rate bursts: sustained bursts of 10+ requests per second.
  • Budgets: daily and hourly limits.

Loops are throttled first (calls spaced 2 seconds apart) and only blocked if the loop is still going 60 seconds later, or once it has cost 2% of your daily budget (minimum $0.25). Budget breaches block straight away. Modes run from Alert (never blocks) to Throttle to Kill Switch, and only the looping feature is affected.

Node.js / TypeScript:

npm install @atlasburn/sdk npx abn init 
import { initAtlasBurnAuto, atlasburnFeature } from "@atlasburn/sdk"; initAtlasBurnAuto({ apiKey: process.env.ATLASBURN_KEY }); // Tag calls with the feature they belong to (the tag is stripped before the provider sees it). await openai.chat.completions.create(params, { headers: atlasburnFeature("support-chat") }); 

A blocked feature gets a provider-shaped 429 with x-atlasburn-blocked: 1 and x-should-retry: false, so well-behaved SDKs stop retrying. If AtlasBurn can't be reached, calls go through as normal (it fails open).

Python or any other language: point your client at the proxy and send the feature as a header:

from openai import OpenAI client = OpenAI( base_url="https://proxy.atlasburn.com/openai/v1", default_headers={"AtlasBurn-API-Key": "<your AtlasBurn ingest key>"}, ) client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": "Hi"}], extra_headers={"x-atlasburn-feature": "support-chat"}, ) 

It reads usage only (model, token counts, timing, your feature label), never prompts or outputs. The SDK is open source (Apache-2.0, github.com/AtlasBurn/sdk), and the free plan covers up to 10M tokens a month with guardrails and the kill switch included.

Docs: Guardrails and the kill switch · Quickstart · Edge Proxy for Python, Go and other languages

FAQ

How do I stop an AI agent from looping and burning API credits?
Cap iterations in the agent framework (e.g. max_turns), and add a circuit breaker in the request path that detects repeated calls or fast context growth per feature and pauses that feature. Keep a provider-level spend limit as the final backstop.

How do I detect a retry storm in an LLM app?
Look at the last few calls of each feature. Several calls to the same model with the same input size within a few seconds is a retry storm. Block or slow that feature before the next call is sent.

How do I track LLM costs per feature?
Attach a feature label to every request (a header, metadata field or tag), record the token usage from each response, and multiply by the model's per-token price. Gateways and tools like LiteLLM, Helicone, Langfuse and AtlasBurn do this for you.

Can I block one AI feature without taking down the whole app?
Yes, if enforcement is scoped per feature rather than per account or API key. Give each feature its own budget/limit (a separate key or tag in a gateway), or use a tool that scopes its kill switch to the feature that's looping.

Do OpenAI and Anthropic have per-feature spend limits?
Not per feature. Their limits and alerts apply at the organization, project or workspace level, so a loop in one feature can trip a limit that stops everything in that project.

What's the worst agent loop you've hit? I'm collecting real incidents to tune the thresholds above, so please share in the comments.