AI Development

Spend Caps: The 429 You Asked For Is Still an Outage

Enforceable spend limits moved LLM cost control out of the billing dashboard and into the production failure path. A cap with no degradation behind it does not save money — it just decides, on your behalf, that the feature customers pay for stops working at 4pm on a Tuesday.

KAKabir AnandLead Developer
8 min read
Editorial photograph of a heavy industrial knife switch mounted on a grey wall panel, thrown to the off position

At 16:12 on a Tuesday, eleven days before the end of the billing period, a client's in-product assistant started returning errors to every user who opened it. Nothing had deployed. No model was down. An org-level spend cap had been reached, and every request under that organisation began coming back 429.

The cap was ours. We had recommended it three weeks earlier, in a document that described it as a safety net. It behaved exactly as documented. The part we had not written down is that a hard limit is not a budget control at all — it is a kill switch wired to a number that a background job can reach on its own, at a time nobody chose, with no human in the loop. That week roughly four percent of the spend belonged to the feature customers were actually paying for. The other ninety-six percent was a re-embedding job somebody had left on a nightly schedule after a migration, and it was the one that pulled the trigger.

The cap is a failure path, not a finance setting

Enforceable monthly limits at both organisation and project level are a genuinely good change. Before them, cost control was an email alert arriving after the money was gone, and every agency has at least one story about a runaway loop discovered on an invoice. Having the platform refuse to spend past a number is strictly better than finding out in arrears.

What changed with them is not the finance conversation. It is where the control lives. A dashboard alert is an operational nuisance; an enforced limit is a branch in your request handling that executes under load, in production, at the worst moment in the month. It belongs in the same category as a database connection pool exhausting or a third-party timing out, and it deserves the same treatment: a defined behaviour, a test, and a runbook. Almost nobody gives it that, because it arrives dressed as a billing checkbox.

Two details make it sharper than a generic dependency failure. The first is the status code. A reached cap returns 429, which is also what rate limiting returns, which is what every sensible client library already retries with exponential backoff. So the default behaviour of a well-built system, on the day the cap fires, is to hammer a limit that will not lift for eleven days — burning latency budget and filling logs with retries that were correct code doing exactly the wrong thing. The distinguishing signal is in the error body, not the status.

The second is that enforcement is not instantaneous. Limit state has to propagate, so spend can overshoot the number slightly before requests start failing. That is fine as an accounting matter and important as an engineering one: the cap is not a precise ceiling you can budget against to the dollar, it is an approximate cliff. Treating it as exact is how teams end up setting it so close to expected spend that ordinary month-end traffic tips them over.

Flat vector illustration of one wide pipe splitting into three lanes of different widths, with the narrowest lane still open and the two wider lanes closed off
The lane you must keep open is almost always the narrowest one. A single cap closes all three at once.

One org, one key, one cap — the thing we stopped doing

The old shape was convenient and it is the default nearly everywhere: one organisation, one API key in the secret store, every workload sharing it. Under that shape a spend cap cannot distinguish between the chat assistant a customer is waiting on and a nightly enrichment job nobody would notice for a week. It only knows the total, so when the total is reached it stops both.

We now split by workload before we set any limit at all, and the split is by blast radius rather than by team or by feature. Three tiers has been enough on every platform we have done this on:

  • 1Revenue-critical, synchronous.A person is waiting and the product looks broken without it. Its own project, its own key, its own cap set generously above the worst month you have ever had. This tier should be the last thing that ever fails and the smallest share of spend — if it is not the smallest share, that is the finding.
  • 2Asynchronous and deferrable. Enrichment, summarisation, classification, backfills. Nobody is watching in real time, so the correct failure is to stop and resume later. This tier gets a tight cap on purpose, because it is the one that runs away.
  • 3Experimental and internal. Evals, prototypes, the notebook someone is iterating in. Capped low enough that a bad loop costs a coffee, and structurally unable to touch the production ceiling.

The projects are cheap to create. The keys are the actual work: on the platform where this bit us, one key was in four services, and pulling it apart took longer than everything else combined. That is normal, and it is worth knowing before you quote it.

Once the split exists, the two error codes stop being trivia and start being triage. A project-scoped failure means one workload exhausted its own allowance while everything else is healthy — usually an incident about that job, not about the platform. An org-scoped failure means every project is out, and the only thing that matters is whether tier one has something to serve.

We set the cap to protect the client from a runaway job. It worked. The runaway job stopped, and so did the product.

Postmortem note, platform team, August 2026
src/llm/dispatch.ts
1// 429 alone is ambiguous — rate limit and spend cap share it.
2// The code in the body decides which incident you are having.
3export async function dispatch(job: Job) {
4  try {
5    return await callModel(job.tier, job.input);
6  } catch (err) {
7    if (!isSpendLimit(err)) throw err; // real 429s still back off
8 
9    // This workload is out. Every other project still has budget.
10    if (err.code === "project_spend_limit_exceeded") {
11      page("budget", job.project);
12      return cheaperRoute(job);
13    }
14 
15    // organization_spend_limit_exceeded — nothing gets a model call.
16    page("budget-org", "all");
17    if (job.tier !== "revenue") return park(job); // resume later
18    return withoutModel(job); // search results + an honest notice
19  }
20}

The last line is the one worth arguing about internally. Deciding in advance what the product does with no model at all is a product decision, not an engineering one, and it is much easier to make in a planning meeting than at 16:12 on a Tuesday. For a support assistant it was keyword search over the same help centre with a one-line notice. For a document tool it was hiding the summarise button rather than letting it fail. Both took under a day to build, and both were only built because someone had been made to answer the question while nothing was on fire.

Degradation you have not fault-injected does not exist

Every team we have said this to agrees immediately and then does not test it, because the failure is annoying to reproduce: you cannot exhaust a real budget on demand, and nobody wants a staging project that has to actually burn money to enter the state you are testing. So the handler gets written, reviewed, merged, and never once executed before the day it matters.

The fix is unglamorous. Inject the error at the client boundary. A flag in the SDK wrapper that makes the next N calls throw a synthetic 429 carrying each spend-limit code in turn, plus a mode that fails only one project, gets you the whole matrix in an afternoon. We run it in CI on the revenue-critical path and manually in staging for the rest.

What that turns up is rarely the code you wrote deliberately. On the first platform we did this on, the degradation path itself worked and three other things did not: a queue consumer that treated the parked jobs as poison and dead-lettered them, a client-side retry that ignored the server's decision and re-fired the same request four times, and a status page component that read model availability from a health check which knew nothing about billing and therefore stayed green throughout. The last one is the one that mattered, because it meant support told customers everything was fine.

Isometric illustration of a tiered stack of platforms where the upper two levels have gone dark and hollow while the lowest level stays lit and intact
Fault injection is the only way to find out which tier is actually load-bearing. Ours was not the one on the architecture diagram.

Set the alerting off thresholds below the cap rather than at it, and make the warning route to whoever can act on it. A notification that spend has reached the ceiling is not an alert, it is a receipt. Seventy percent with eight days remaining is an alert, because someone can still choose between raising the limit, pausing a job, or letting the tight tier stop on purpose.

What we do before turning a cap on now

  • Split projects by blast radius — synchronous and revenue-critical, deferrable, experimental — and give each its own key before any limit is set.
  • Write down what the product does with no model access at all, per surface, and get a product decision rather than an engineering guess.
  • Distinguish spend-limit 429s from rate-limit 429s at the client boundary, because retrying the first one is guaranteed to be wrong.
  • Fault-inject both error codes in CI on the revenue path, and check what the queue, the client retry and the status page do, not just the handler.
  • Set warning thresholds well below the cap, routed to someone with authority to raise it, and treat reaching the cap itself as an incident rather than a notification.
  • Reconcile provider spend against per-feature attribution monthly, so the tier that is quietly consuming the budget is visible before it is the thing that trips the switch.

If you only change one thing this week

Find out which single workload would take the product down if it ran away, and move it into its own project with its own key and its own limit. That one change converts an outage into a bad night for a batch job, and it is a couple of hours of work on most codebases.

Then ask the question that started all of this: if every model call returned an error for the next hour, what would a customer see? If the answer is a spinner, an error toast, or a support ticket, the cap is not protecting anyone yet. It is just deciding when the outage happens — and it will pick a worse moment than you would have.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.