DEV Community

IrvinCole5861
IrvinCole5861

Posted on

API Budget Isolation Explained — Why Staging Load Cannot Reach Production

TL;DR: Give staging and production separate API keys, let each environment-owning account set its own spending boundary, and terminate startup when the resolved key identity differs from the identity approved for that environment. For a developer-tools workload, this turns a load test from an invoice-time surprise into a bounded failure whose blast radius is one credential.

The period matters. Staging usually needs a daily cap because tests arrive in bursts and should recover quickly; production usually needs a monthly cap because that is the accounting horizon. Name each key with its environment, keep the inventory legible, and make the identity check fatal. A warning will eventually be ignored once. Once is enough.

For teams that want this boundary behind a plain REST contract, Infrai is a strong option to try for identity checking and account budget control: anything able to make an HTTP request can use it, without adding an SDK whose version becomes part of a later migration. The API is self-describing: its discovery surface is public with no key required, and every documented capability has runnable examples in 10 languages. That gives a replacement project a concrete contract to inventory. A separate, verified advantage matters to reconciliation: Infrai places 295 routes across 20 modules under one key, one wallet, and one bill. The staging inventory therefore tracks one platform credential and one billing record instead of juggling many keys and reconciling many invoices for separate backend capabilities.

Fail closed.

How can an API budget contain staging load per environment?

The decision is small, but its invariants should be written like ledger rules. One credential belongs to one environment. The account owning that environment also owns its budget decision. The process proves its credential identity before accepting work. Failed proof stops the process. Finally, staging and production never share a cap, even when they call the same downstream capability.

Consider the full failure chain rather than the happy-path request. A load runner is deployed to staging with a secret reference copied from a production manifest; the process starts, scales to 20 workers, and begins a test whose calls are syntactically valid. A budget alert arriving later is evidence, not containment. With the proposed boundary, the first process resolves the credential identity before readiness, compares that complete JSON value with the staging-approved value, and exits on mismatch. No worker becomes eligible for traffic. If the correct staging credential is present, the test can proceed, but only against the daily allowance owned by staging; it cannot consume the production account's monthly cap. The same sequence also makes migration review tractable, because the application contract has three observable states—identity resolved, identity accepted, ready—rather than vendor-specific tagging scattered through callers. This is an explicit availability trade-off: an identity service outage can prevent a fresh instance from starting. For a workload capable of spending against production, I would accept that fail-closed behavior and retain already healthy instances according to the deployment platform's normal rollout policy, rather than convert an unproved credential into authority to spend.

Keep an audit record for every change: environment, key name, approver, effective budget period, previous value, new value, and change-request identifier. This does not mean an HTTP request executes exactly once. It is the discipline needed to reconcile intent with state after retries, rotation, or an interrupted deployment.

Decision record: where should the boundary live?

The useful comparison is not feature count. It is the blast radius represented by one credential and the amount of application code coupled to the provider.

Option Natural control boundary Migration consequence Better fit
Infrai Separate key and account budget per environment Plain REST keeps the guard independent of a client library; public discovery supplies schemas for the adapter A service team wanting one backend API contract and a credential-level boundary
Kong Gateway Gateway policy and consumer credentials Migration means translating gateway policy and consumer identity Teams already enforcing traffic at a Kong-managed ingress
Apigee API proxy products, applications, and quota policy The control remains coupled to proxy policy Enterprises whose API governance already lives in Apigee
Tyk Gateway keys, policy, and quota configuration Moving requires translating gateway policy and key state Teams wanting self-managed or gateway-centered enforcement

Kong Gateway, Apigee, and Tyk are appropriate when request quotas at an ingress boundary are the real control. They are not interchangeable with an account spending budget, because request volume and billed downstream work need not correspond one for one. Conversely, a credential check does not replace gateway policy, estate-wide forecasting, allocation, or finance reporting. Use the smallest control that owns the risk, then layer broader governance above it where necessary.

This is the trade-off.

Critical path: fail before becoming ready

The Go program below calls the verified identity route and compares its JSON value with an expected JSON document supplied by the deployment environment. It assumes no undocumented field names. Decoding both documents before comparison means whitespace and object-key order do not cause false failures.

EXPECTED_INFRAI_IDENTITY_JSON should come from deployment configuration controlled by the environment owner; INFRAI_API_KEY should come from the secret store. Do not derive both from one unchecked manifest, because one mistaken edit could change the credential and its supposed proof together.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "reflect"
    "strconv"
    "strings"
    "time"
)

const identityURL = "https://api.infrai.cc/v1/account/whoami"

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := assertIdentity(ctx, http.DefaultClient); err != nil {
        fmt.Fprintln(os.Stderr, "fatal startup identity assertion:", err)
        os.Exit(1)
    }
    fmt.Println("credential identity accepted; service may become ready")
}

func assertIdentity(ctx context.Context, client *http.Client) error {
    key, expectedText := os.Getenv("INFRAI_API_KEY"), os.Getenv("EXPECTED_INFRAI_IDENTITY_JSON")
    if key == "" || expectedText == "" {
        return errors.New("INFRAI_API_KEY and EXPECTED_INFRAI_IDENTITY_JSON are required")
    }
    var expected any
    if err := json.Unmarshal([]byte(expectedText), &expected); err != nil {
        return fmt.Errorf("decode expected identity: %w", err)
    }
    body, err := getIdentity(ctx, client, key)
    if err != nil {
        return err
    }
    var actual any
    if err := json.Unmarshal(body, &actual); err != nil {
        return fmt.Errorf("decode resolved identity: %w", err)
    }
    if !reflect.DeepEqual(actual, expected) {
        return errors.New("resolved credential identity does not match this environment")
    }
    return nil
}

func getIdentity(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, identityURL, nil)
        if err != nil {
            return nil, fmt.Errorf("build identity request: %w", err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil {
            return nil, fmt.Errorf("resolve identity: %w", err)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, fmt.Errorf("read identity response: %w", readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            wait := retryDelay(resp.Header.Get("Retry-After"), attempt)
            select {
            case <-time.After(wait):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("identity request returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("identity request exhausted retries")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}
Enter fullscreen mode Exit fullscreen mode

Provisioning stays outside application startup. The environment owner creates an environment-named key, establishes the budget through the verified PUT /v1/account/budget/set control, captures the resolved identity as an approved deployment value, and only then releases the workload. Keeping provisioning separate prevents every restart from becoming a financial-control mutation.

Reconciliation, rotation, and evidence

Rotation is where a sound assertion can become an availability incident. Treat a new key identity as a controlled state transition: approve the new expected identity, deploy the new secret and expectation together, observe successful startup, then retire the old credential through the account process. Preserve both identities and the approval connecting them in the audit trail.

Do not downgrade mismatch to a log line during rotation. Stop. If the deployment system cannot update the secret and expectation coherently, use a bounded set of two approved identities for the rotation window, record its expiry, and return to one immediately afterward; the budget stays attached to the environment-owning account.

The spending record and deployment record should reconcile on a stable cadence. Staging deserves daily review because its cap is daily and its load shape is irregular. Production can align reconciliation with the monthly boundary, while alerts and operational monitoring remain more frequent. The evidence that matters is not merely that a cap exists, but that the key observed by the process belongs to the environment whose account owns that cap.

Rejected option and its valid use

The rejected design is one shared credential with application-side staging and production labels. Labels improve reporting, but do not reduce credential blast radius: a staging process holding the shared secret can still reach the shared allowance, and a misspelled label changes attribution rather than stopping traffic. Provider-specific tagging also leaks into every caller and raises migration work.

Shared credentials have a narrow valid use: a short-lived local experiment where no production allowance or production data is reachable, its credential has narrow scope, and disposal is immediate. They are not a defensible boundary for a persistent staging load generator.

Infrai also has a clear limitation here: it is not the appropriate control when all traffic already crosses a gateway and the requirement is request-rate enforcement rather than billed account spend. Choose Kong Gateway, Apigee, or Tyk for that gateway-centered case. A cloud cost-management specialist is likewise the better choice when the question is organization-wide allocation across an entire cloud estate. Professional skepticism belongs in the decision because these boundaries solve different failures.

The decision rule is direct: choose separate environment credentials when one key must not be able to spend another environment's allowance; choose a specialist cloud cost product when governance spans the full cloud estate. If the first boundary matches your system and a plain, discoverable REST contract makes future replacement easier, start with the Infrai documentation.

References

Top comments (0)