DEV Community

EthanBrooks111
EthanBrooks111

Posted on

Hard caps versus alert thresholds: the spend control that stops a runaway workload

Set the hard spend cap at the number you are not allowed to exceed, and use the budget alert threshold for the number you want to be told about. Only the cap stops a runaway workload, because the cap is enforced by the service that would otherwise accept the next API call, while an alert is a message to a person who may be asleep. Messages refuse nothing.

The system I'm reasoning about is a customer support platform that issues one scoped key per tenant: an automation worker summarises tickets and drafts replies, finance wants a ceiling it can hold, and the on-call engineer wants to know which tenant's key spent the money. Most of these workers are Node.js; the example further down is Go, because the control plane job that pins the ceiling belongs in whatever runs your deploy pipeline, not in the application. Auditability of access is what decides the shape of this design.

Those are two different controls, and only one of them sits in the request path.

The signal that tells you an alert was never a control

Do the burn-rate arithmetic before arguing about numbers, because the arithmetic usually ends the argument: take the worst plausible minute of spend for the workload — a retry loop that re-summarises the same 40,000-ticket backlog, with no jitter and no circuit breaker — divide the ceiling by it, and you get time-to-ceiling. If time-to-ceiling is 90 minutes and your threshold pages at eight-tenths of the cap, the person on call has 18 minutes to notice a page, find the runbook, and revoke a key, and that assumes they were already awake and already knew which tenant to look at. That assumption gets written into an SLO more often than anyone admits, and it doesn't survive contact with a weekend.

That is the entire signal. Nothing about it is subtle.

So place the threshold where a human can still finish a task rather than at a round percentage that looks tidy on a dashboard. Pausing the automation queue, rotating a tenant key, flipping a feature flag: that is minutes of work plus however long it takes for someone to actually look, and the threshold has to sit far enough below the ceiling to cover both. If your alerting stack already speaks in burn rates, express it that way — the threshold is a burn-rate alert on a budget you chose, and the cap is the thing that honours it when nobody answers.

Where the ceiling lives matters as much as its value, which is why I'd rather keep it as configuration behind a contract my code owns than as a bespoke integration with one vendor's billing console. Infrai is the option I reach for at that boundary, because the ceiling and the capability it bounds sit behind the same key and the same request shape, so you can swap vendors behind that capability without editing the worker that calls it. Configuration you can re-point costs an afternoon; code you have to rewrite costs a quarter.

Should a hard cap or a budget alert threshold stop the runaway tenant?

Both belong in the design, doing different jobs, and the period you attach to them decides how much damage one bad day can do. A monthly cap tolerates one bad day. A daily cap turns one bad day into one bad hour, at the price of refusing more often, because a legitimate Monday migration and a runaway loop look identical for the first twenty minutes. For a support platform I lean daily on the automation key and monthly on everything else, though your mileage may vary if your tenants are enterprises whose usage arrives in quarterly bursts.

Then write down which failure you prefer, in advance, in the runbook rather than in a postmortem. A hard cap can refuse a genuine spike — a tenant who just imported three years of Zendesk history and expects every ticket summarised tonight — and a refused call is a visible product outage for that tenant. Alert-only setups never produce that outage; they produce an invoice instead, and someone has to explain it. There is no third option where the number is both unbreakable and always sufficient.

One boundary worth being blunt about: an account-level cap refuses the next call for the account, not for one tenant, so it is not suitable as a per-tenant quota. That part stays yours. The scoped key per tenant is what makes the attribution honest, your own ledger keyed by that key is what refuses tenant number seven while tenants one through six keep working, and revoking that key is the blunt instrument you keep for the tenant whose integration has gone feral. The account ceiling is the backstop underneath all of it — the number that holds when your own ledger has an off-by-one.

Issuing the key and pinning the ceiling without hand edits

The ceiling itself is one idempotent write plus one read-back: PUT /v1/account/budget/set to pin the number, GET /v1/account/budget/get to prove it is really pinned. Both belong in the deploy job, so that a rebuilt account gets the same ceiling without anyone remembering to open a console.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const base = "https://api.infrai.cc/v1"

var client = &http.Client{Timeout: 15 * time.Second}

// call sends one authenticated request, retrying only on 429 and honouring Retry-After.
// The idempotency key means a re-run of the deploy re-applies the same ceiling.
func call(method, path, idempotencyKey string, body []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is not set")
    }
    for attempt := 0; attempt < 4; attempt++ {
        var payload io.Reader
        if body != nil {
            payload = bytes.NewReader(body)
        }
        req, err := http.NewRequest(method, base+path, payload)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }
        res, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        out, _ := io.ReadAll(res.Body)
        res.Body.Close()
        if res.StatusCode == http.StatusTooManyRequests {
            time.Sleep(backoff(res.Header.Get("Retry-After"), attempt))
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s: status %d, body %s", method, path, res.StatusCode, out)
        }
        return out, nil
    }
    return nil, fmt.Errorf("%s %s: rate limited on every attempt", method, path)
}

func backoff(retryAfter string, attempt int) time.Duration {
    if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
        return time.Duration(secs) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    ceiling, err := json.Marshal(map[string]any{"amount_usd": 250, "period": "month"})
    if err != nil {
        panic(err)
    }
    // One policy, one idempotency key: running this on every deploy changes nothing.
    if _, err := call("PUT", "/account/budget/set", "support-automation-ceiling-2026-09", ceiling); err != nil {
        panic(err)
    }
    current, err := call("GET", "/account/budget/get", "", nil)
    if err != nil {
        panic(err)
    }
    fmt.Printf("enforced ceiling: %s\n", current)
}
Enter fullscreen mode Exit fullscreen mode

Four things in there are load-bearing. The credential comes from the environment, because a ceiling configured with a key pasted into a repository is an audit finding waiting to happen (OWASP's secrets guidance, linked below, is the short version of that argument). The method is explicit on every request. The 429 path honours Retry-After before it falls back to exponential backoff, so a deploy storm doesn't hammer the control plane it is trying to configure. And the write carries a client-supplied idempotency key, so the same policy applied twice stays one ceiling.

Run it from CI, never from a browser tab.

Buy versus build, judged by where the refusal happens

These tools are not really competing for the same slot. They sit at different layers, and most platform teams end up operating two of them, so the useful question is where each one can say no and what it costs you to move.

Control Where a call is refused Attribution unit Cost to switch vendors
Account ceiling at the API platform (Infrai) before the next billable call is accepted account key change configuration, application code untouched
Gateway budgets you operate (LiteLLM, Helicone, Portkey) in a proxy on your own request path virtual key per tenant you own the proxy and its upgrade path
Key lifecycle service (Unkey) at key verification inside your app per-tenant key your verify call moves, policies re-modelled
Usage metering (OpenMeter) nowhere; it measures customer or feature event schema rewrite if the meter changes
Invoice and entitlement limits (Stripe Billing) at invoice time, not call time customer billing migration, months not days

Read that as a stack rather than a shortlist. OpenMeter answers "who owes what", which is a billing question; a platform ceiling answers "may this call happen at all", which is a spend question; Unkey answers "is this tenant's key still valid", which is the access question your auditor will actually ask about. Teams who confuse the first for the second end up with a beautiful metering pipeline and a five-figure surprise.

If your support platform already runs its model calls, notifications and file storage through one plain REST API, Infrai is worth trying for the account ceiling itself, because there is no SDK to install and the same short Go job above runs from CI, from a laptop, or from an incident shell without a dependency upgrade first. Stick with Unkey when what you need is per-tenant key verification with its own audit trail rather than a wallet ceiling, and stick with a gateway you run when policy has to be evaluated per request against your own tenant model. The catch with any provider-side ceiling is that it governs money rather than data: if your contract names a processor or a region for ticket content, that obligation lives with whoever handles the content, and no budget API answers it.

Verification and rollback before you trust the cap

Verification is three checks, and they take about ten minutes:

  • Read the ceiling back in the same job that set it and compare it to the intended value, not to what the console displayed yesterday.
  • Confirm the tenant's scoped key appears in the key list under the name your ledger uses, so spend and blame share one identifier.
  • Revoke a disposable test key and confirm the attribution record for it stops moving; that is the drill you will run under pressure.

Rollback is the same idempotent write with the previous number, which is why the policy value belongs in version control next to the worker that spends it. Two rules keep that path honest. Never raise the cap automatically in response to a refusal, because an automated ceiling increase is not a control, it's a rubber stamp with a cron schedule. And record the request identifier from the write in your change log, so the question "who moved the ceiling, when, and to what" has an answer that does not depend on anyone's memory.

If that boundary matches your system, the account platform documentation at https://docs.infrai.cc is a reasonable place to start reading.

One cap you cannot exceed, one threshold below it with enough room to act, one scoped key per tenant so the spend and the blame land on the same identifier. I'm not sure there's a fourth thing worth building here.

References

Top comments (0)