Azure.NETDevOpsKostenbeheerAI

Harde budgetcaps: waarom je .NET-app zelf de stekker moet kunnen trekken

Simon Willison wil standaard harde budgetcaps op elke betaalde dienst. AWS en Google bewegen, Azure pay-as-you-go niet. Zo bouw je een echte kostenrem in je .NET-app en je Azure-infra.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 5 oktober 2026 · 12 min lezen
Harde budgetcaps: waarom je .NET-app zelf de stekker moet kunnen trekken

Een budgetmail om drie uur 's nachts houdt niks tegen. Op het moment dat je hem leest, heeft die op hol geslagen functie al acht uur lang tokens verbrand. Simon Willison schreef er afgelopen weekend een kort stuk over: we hebben standaard harde budgetcaps nodig, op zo ongeveer alles. Op Hacker News kwam het boven de 600 punten uit, met ruim 300 reacties. Dat zegt wel iets over hoeveel mensen al eens zijn geschrokken van een factuur.

Zijn punt is simpel. Na een bepaald bedrag per maand moet een dienst stoppen en errors teruggeven. In zijn woorden: "These need to be hard limits. Soft caps, 'after $X/month, send me a warning email', will not cut it." Hij koppelt dat aan coding agents. Nu iedereen in een middag een app kan laten deployen, kan ook iedereen in een middag een dure fout deployen.

Ik ben het grotendeels met hem eens. Maar als .NET-freelancer die vooral op Azure werkt, vind ik de vraag interessanter wat je vandaag al kunt doen. De provider gaat je namelijk niet redden.

Wat de grote clouds nu bieden

AWS en Google hebben deze zomer allebei iets uitgebracht. AWS heeft spend limits per project die het hele project pauzeren als de limiet bereikt is. Je data blijft bewaard, maar onderneem je 90 dagen niets, dan verwijdert AWS de projectdata definitief. Daarnaast zijn er optionele vroege remmen. Zo'n zeven dagen voor de limiet stopt AWS met het opstarten van nieuwe resources, en vier dagen ervoor kun je de grootste kostenposten laten pauzeren (EC2, RDS, Lambda, Bedrock en SageMaker). AWS waarschuwt er zelf bij dat dit "a disruptive experience" kan zijn, en dat de feature nog maar bij een beperkt aantal klanten wordt uitgerold.

Google heeft Spend Caps in public preview, voor de Gemini API, Agent Platform, Cloud Run en Cloud Run Functions. Volgens Google grijpen de caps voor AI-diensten binnen enkele minuten in. Vaste kosten zoals committed use discounts en provisioned throughput lopen gewoon door.

En Azure? Daar bestaat een spending limit, maar alleen voor abonnementen met tegoed, zoals een free account of een Visual Studio-abonnement. Microsoft zegt het letterlijk: de spending limit "isn't available for subscriptions with commitment plans or with pay-as-you-go pricing". Een eigen bedrag instellen kan ook niet. Het is het tegoed of niks.

Voor een productieklant op pay-as-you-go heb je dus budgetten met alerts. En die alerts lopen achter. Kostendata komt volgens de documentatie doorgaans binnen 8 tot 24 uur binnen, en budgetten worden eens per 24 uur geëvalueerd. Een LLM-endpoint dat in een lus terechtkomt, heeft in 24 uur een hoop ruimte.

De tegenstem uit de discussie

Op Hacker News was niet iedereen enthousiast. Een reageerder die support had gedaan voor een dienst met harde caps beschreef "tons of tickets and even threats of lawsuits" van klanten wier dienst op het slechtst denkbare moment werd afgesloten. Anderen wezen erop dat billing-systemen asynchroon werken, waardoor echte realtime handhaving bij de provider lastig is.

Allebei terecht. En allebei een argument om de cap dichter bij je eigen code te leggen. Jij weet welke feature mag stoppen en welke niet. De cloudprovider ziet alleen een abonnement en een bedrag. Die kan een checkout-flow niet onderscheiden van een experimentele samenvattingsfunctie die een klant ooit heeft aangezet.

Daarom werk ik met drie lagen. Elke laag is grover en trager dan de vorige.

Laag 1: de applicatie, voor kosten per verzoek

Het grootste risico in de meeste SaaS-apps die ik tegenkom zit op dit moment in LLM-aanroepen. Die kosten geld per verzoek, ze schalen met invoer die je niet volledig beheerst, en een retry-lus of een agent die zichzelf blijft aanroepen jaagt de rekening in een paar minuten omhoog.

De fout die ik het vaakst zie: achteraf tellen. Je logt het tokengebruik na elke call en een achtergrondjob checkt elk uur of een tenant over zijn budget zit. Dat is precies hetzelfde vertragingsprobleem als bij Azure Budgets, alleen in het klein. Wil je een harde cap, dan moet je vooraf reserveren.

Het patroon is hetzelfde als bij een creditcard-autorisatie. Je reserveert het maximale bedrag dat een aanroep kan kosten, je voert de aanroep uit, en daarna verreken je het werkelijke bedrag. Lukt de reservering niet, dan gaat de aanroep niet door.

Het grootboek in Postgres

Eén tabel en een atomaire update zijn genoeg. De WHERE-clausule doet het eigenlijke werk: de update slaagt alleen als het bedrag nog onder de cap past, en twee gelijktijdige verzoeken kunnen dus niet samen over de grens heen.

create table llm_budget (
    tenant_id    text   not null,
    month        date   not null,
    cap_micros   bigint not null,
    spent_micros bigint not null default 0,
    primary key (tenant_id, month)
);

Ik reken in micro-euro's (1 euro = 1.000.000). Een enkele aanroep kost vaak een fractie van een cent, en met hele centen rond je dat structureel verkeerd af.

using Npgsql;

public readonly record struct Reservation(string TenantId, DateOnly Month, long Micros);

public interface IBudgetLedger
{
    Task<Reservation?> TryReserveAsync(string tenantId, long micros, CancellationToken ct);
    Task SettleAsync(Reservation reservation, long actualMicros, CancellationToken ct);
}

public sealed class PostgresBudgetLedger(NpgsqlDataSource db) : IBudgetLedger
{
    public async Task<Reservation?> TryReserveAsync(string tenantId, long micros, CancellationToken ct)
    {
        var now = DateTime.UtcNow;
        var month = new DateOnly(now.Year, now.Month, 1);

        await using var cmd = db.CreateCommand("""
            update llm_budget
               set spent_micros = spent_micros + $3
             where tenant_id = $1
               and month = $2
               and spent_micros + $3 <= cap_micros
            """);
        cmd.Parameters.AddWithValue(tenantId);
        cmd.Parameters.AddWithValue(month);
        cmd.Parameters.AddWithValue(micros);

        var rows = await cmd.ExecuteNonQueryAsync(ct);
        return rows == 1 ? new Reservation(tenantId, month, micros) : null;
    }

    public async Task SettleAsync(Reservation r, long actualMicros, CancellationToken ct)
    {
        await using var cmd = db.CreateCommand("""
            update llm_budget
               set spent_micros = spent_micros + ($3 - $4)
             where tenant_id = $1 and month = $2
            """);
        cmd.Parameters.AddWithValue(r.TenantId);
        cmd.Parameters.AddWithValue(r.Month);
        cmd.Parameters.AddWithValue(actualMicros);
        cmd.Parameters.AddWithValue(r.Micros);
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

Let op dat de maand in de reservering zit. Een aanroep die om 23:59:58 op de laatste dag van de maand begint, wordt dan verrekend in de maand waarin hij gereserveerd is, ook als hij pas na middernacht klaar is.

Bestaat er geen rij voor een tenant, dan slaagt de reservering niet. Dat is fail-closed, en dat is bewust. Een tenant zonder budget krijgt geen LLM-aanroepen. Wie dat te streng vindt, maakt bij onboarding een rij aan met een redelijke standaardcap.

De rem als IChatClient-decorator

Met Microsoft.Extensions.AI kun je dit als decorator om elke IChatClient heen zetten, zodat geen enkele feature eromheen kan.

using Microsoft.Extensions.AI;

public sealed record ModelPrice(decimal EurPerMillionInput, decimal EurPerMillionOutput)
{
    // tokens * (euro per miljoen tokens) is precies het bedrag in micro-euro's
    public long ToMicros(long inputTokens, long outputTokens) =>
        (long)Math.Ceiling(inputTokens * EurPerMillionInput + outputTokens * EurPerMillionOutput);
}

public sealed class BudgetExceededException(string tenantId)
    : Exception($"LLM-budget voor tenant '{tenantId}' is op.");

public interface ITenantContext { string TenantId { get; } }

public sealed class BudgetedChatClient(
    IChatClient inner, IBudgetLedger ledger, ITenantContext tenant, ModelPrice price)
    : DelegatingChatClient(inner)
{
    private const int DefaultMaxOutput = 1024;

    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        var list = messages as IList<ChatMessage> ?? messages.ToList();

        // Zonder plafond op de output bestaat er geen worst case.
        options = options?.Clone() ?? new ChatOptions();
        options.MaxOutputTokens ??= DefaultMaxOutput;

        var worstCase = price.ToMicros(EstimateInputTokens(list), options.MaxOutputTokens.Value);
        var reservation = await ledger.TryReserveAsync(tenant.TenantId, worstCase, cancellationToken)
            ?? throw new BudgetExceededException(tenant.TenantId);

        var actual = worstCase;
        try
        {
            var response = await base.GetResponseAsync(list, options, cancellationToken);
            if (response.Usage is { InputTokenCount: long input, OutputTokenCount: long output })
                actual = price.ToMicros(input, output);
            return response;
        }
        finally
        {
            await ledger.SettleAsync(reservation, actual, CancellationToken.None);
        }
    }

    // Ruwe schatting: ongeveer 3 tekens per token, plus marge voor rollen en opmaak.
    private static long EstimateInputTokens(IList<ChatMessage> messages) =>
        messages.Sum(m => (long)(m.Text?.Length ?? 0)) / 3 + 64;
}

Registreren gaat via de builder:

builder.Services.AddSingleton<IBudgetLedger, PostgresBudgetLedger>();
builder.Services.AddSingleton(new ModelPrice(EurPerMillionInput: 2.50m, EurPerMillionOutput: 10m));

builder.Services
    .AddChatClient(innerChatClient)
    .Use((inner, sp) => new BudgetedChatClient(
        inner,
        sp.GetRequiredService<IBudgetLedger>(),
        sp.GetRequiredService<ITenantContext>(),
        sp.GetRequiredService<ModelPrice>()));

De prijzen hierboven zijn voorbeeldwaarden. Zet de echte tarieven van je model in configuratie en houd ze bij. ITenantContext moet hier een singleton zijn die de tenant uit IHttpContextAccessor of een eigen AsyncLocal haalt, omdat de chat client standaard als singleton wordt geregistreerd.

Een paar keuzes die je bewust moet maken.

Bij een exception houd ik de volledige reservering vast. Een time-out aan mijn kant betekent niet dat de provider niets in rekening brengt, dus ik zit liever aan de veilige kant. Dat kost hooguit wat ruimte in het budget. Krijg je veel 429's van de provider, dan wordt dat irritant. In dat geval maak je een uitzondering voor responses waarvan je zeker weet dat ze niets kosten.

Streaming moet je apart afvangen. DelegatingChatClient stuurt GetStreamingResponseAsync gewoon door naar de onderliggende client, dus zonder override loopt elke streaming-aanroep om je cap heen. Overschrijf die methode met dezelfde reservering en lees het werkelijke gebruik uit de UsageContent in de laatste updates.

En vang BudgetExceededException op in je API-laag. Geef een duidelijke status terug en een melding die de gebruiker begrijpt. Een generieke 500 is hier de slechtste keuze. Dan gaat de frontend opnieuw proberen.

Laag 2: de infrastructuur, voor kosten per uur

Niet alles loopt via je eigen code. Een functie die door een queue-backlog opschaalt naar honderden instances, of een debug-logniveau dat iemand in productie heeft laten staan: dat soort kosten zie je in de applicatielaag niet. Azure heeft hier een paar knoppen die wel echt hard zijn, alleen staan ze verspreid.

Een maximum aantal instances voor Azure Functions op het Consumption-plan:

az resource update \
  --resource-type Microsoft.Web/sites \
  -g rg-prod -n func-orders/config/web \
  --set properties.functionAppScaleLimit=10

Op Flex Consumption heet het anders:

az functionapp scale config set -g rg-prod -n func-orders --maximum-instance-count 20

Een daglimiet op de ingestie van je Log Analytics-workspace, in GB per dag:

az monitor log-analytics workspace update -g rg-prod -n log-prod --quota 2

Die laatste heeft een prijs. Is de cap bereikt, dan stopt de ingestie voor de rest van de dag en ben je blind tot de reset. Ik zet hem daarom ruim boven het normale volume. Zo vangt hij een ontsporing af zonder dat een drukke dag je je telemetrie kost. Voor Azure OpenAI geldt iets vergelijkbaars: de tokens-per-minuut-quota per deployment is een harde snelheidsgrens. Over een hele maand gemeten zegt dat weinig. Wel kan een ontspoorde lus er niet sneller door dan jij toestaat.

Laag 3: het abonnement, als laatste vangnet

Pas onderaan komt het budget op abonnementsniveau, gekoppeld aan een action group die iets doet. Een mail sturen telt niet.

targetScope = 'subscription'

param actionGroupId string
param startDate string = '2026-10-01'

resource budget 'Microsoft.Consumption/budgets@2023-11-01' = {
  name: 'sandbox-hard-stop'
  properties: {
    category: 'Cost'
    amount: 150
    timeGrain: 'Monthly'
    timePeriod: {
      startDate: startDate
    }
    notifications: {
      actual100: {
        enabled: true
        operator: 'GreaterThanOrEqualTo'
        threshold: 100
        thresholdType: 'Actual'
        contactEmails: [ 'ops@example.nl' ]
        contactGroups: [ actionGroupId ]
      }
    }
  }
}

De action group roept een runbook of een kleine functie aan die resources met een tag als costcap=stop stopt of terugschaalt. Voor sandbox- en demo-abonnementen werkt dat prima. Voor productie zou ik er nooit op vertrouwen als eerste verdediging, juist door de vertraging van 8 tot 24 uur plus de dagelijkse evaluatie. Zie het als het luchtkussen onder de vangrails.

Wat Willison goed ziet, en wat ontbreekt

Willison heeft gelijk dat de standaard verkeerd staat. Iemand die een hobbyproject op een cloudaccount zet, hoort geen onbegrensde aansprakelijkheid te hebben omdat hij een vinkje niet kende. Dat AWS en Google nu met echte caps komen is goed nieuws, en ik hoop dat Microsoft volgt voor pay-as-you-go.

Voor wie software bouwt waar klanten op draaien, ligt het anders. Een cap op providerniveau is altijd grof: het hele project, of de hele dienst, gaat dicht. De reacties op Hacker News over afgesloten klanten en dreigende rechtszaken gaan precies daarover. De oplossing is fijnmaziger begrenzen, per tenant en per feature, op de plek waar je nog weet wat je afsluit.

Er zit nog een bijeffect aan dat ik onderschat had toen ik er voor het eerst over nadacht. Zodra er per feature een cap in een tabel moet staan, moet iemand dat getal kiezen. Dan voer je ineens het gesprek over wat een feature per gebruiker mag kosten. Dat gesprek had er vóór de eerste dure maand al moeten zijn.

Veelgestelde vragen over harde budgetcaps en LLM-kosten

Heeft Azure een harde spending limit voor pay-as-you-go-abonnementen? Nee, de Azure spending limit bestaat alleen voor abonnementen met tegoed, zoals een free account of een Visual Studio-abonnement, en het bedrag is altijd gelijk aan dat tegoed. Voor pay-as-you-go heb je budgetten met alerts en action groups, en die werken met kostendata die 8 tot 24 uur achterloopt.

Waarom reserveren vooraf en niet gewoon achteraf tellen? Omdat achteraf tellen pas ingrijpt nadat het geld al uitgegeven is, en gelijktijdige verzoeken allemaal nog door de controle komen voordat de teller bijgewerkt is. Een atomaire reservering in de database laat elk verzoek eerst ruimte claimen, waardoor de cap ook onder hoge gelijktijdigheid echt hard blijft.

Wat doe ik met een gebruiker die midden in een gesprek tegen zijn cap aanloopt? Geef een duidelijke foutmelding met de reden en wanneer het budget weer vrijkomt, en laat de frontend niet automatisch opnieuw proberen. Bij betalende klanten is terugvallen op een goedkoper model een redelijke tussenstap voordat je helemaal stopt, zolang je dat ook via dezelfde reservering laat lopen.

Is een daglimiet op Log Analytics verstandig in productie? Ja, als vangnet tegen een vergeten debug-logniveau of een logstorm, mits je hem ruim boven je normale dagvolume zet. Zodra de cap bereikt is stopt de ingestie tot de dagelijkse reset, dus een te krappe limiet maakt je blind op precies de dag dat er iets misgaat.

Verder lezen


Conclusie: Wacht niet tot Azure een harde cap voor pay-as-you-go levert. Leg de rem waar je nog weet wat je afsluit: een reservering per tenant om elke LLM-aanroep, harde schaal- en ingestielimieten op je infra, en pas helemaal onderaan een abonnementsbudget dat daadwerkelijk iets uitzet.

Bronnen: Simon Willison: We're going to need default hard budget caps on pretty much everything · Hacker News-discussie · AWS: spend limits · Google Cloud: Spend Caps · Microsoft: Azure spending limit · Microsoft: budgetten in Cost Management.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op