Cost and Reliability
Build a Chatbot: From API Call to Deployed Bot
Chapter 7 ยท Cost, Rate Limits and Reliability
A bot that works in testing meets three problems the day real people use it. It costs money on every message, and one enthusiastic user (or one abusive script) can run up a bill. Its users send messages in bursts, and its provider has limits of its own. And the provider fails: it rate-limits you, it is overloaded, it times out, or it is simply down. This chapter gives the bot a meter, a set of limits, and a layered defence against failure: retries, a circuit breaker and a backup provider.
FakeProvider, fake clocks, a fake sleep and a local scripted server; the project now has 135 passing tests. No real provider was called, so nothing here
measures a real service's latency or limits. The prices in the demonstrations are made-up examples, not any vendor's: copy real ones from your provider's pricing page, with the date. The provider facts come from
Anthropic's rate-limit and error documentation, which I read; the jitter advice comes from an AWS Architecture Blog article on backoff, whose formulas are images I could not read, so the exact formula below is the standard "full jitter" definition
as I implement it, which my own simulation then tests.
What Providers Tell You About Limits
Anthropic's rate-limit page (the one I read; other vendors differ) describes the shape of the problem:
- Rate limits are measured in requests per minute, input tokens per minute and output tokens per minute for each model class, applied with a token bucket (capacity is continuously replenished rather than reset at fixed intervals). A limit of 60 requests a minute may be enforced as one a second, so short bursts can trigger a 429. A 429 carries a
retry-afterheader. - Only uncached input tokens count toward the input limit for most models, and
max_tokensdoes not count toward the output limit; the output limit counts tokens actually generated. (Cost is a separate matter: you pay for the tokens generated.) - Spend limits are different from rate limits. Each tier has a monthly cap (the page lists $500 for the Start tier, $1,000 for Build and $200,000 for Scale; check the current page). When the cap is reached, requests return a 429 with the error type
rate_limit_errorbut noretry-afterheader, a detail fielderror_code: "enforced_spend_limit_reached", and they keep failing until access resumes. A spend limit that you set returns a 400 instead. - The official SDK, per the errors page, retries transient failures twice by default, honouring
retry-after. This course builds the equivalent itself, because it works for any provider.
The spend-cap 429 is a trap for a naive retry loop: it looks like an ordinary rate limit but waiting will not help. The Claude provider from chapter 2 now recognises it and raises a new error, QuotaExhausted.
I ran the three situations through the provider against a local server that replies in the documented shapes:
Each error class now carries two flags: retryable (would trying the same provider again help?) and fallback_ok (would trying a different provider help?). A spent quota, a dead key and an overloaded
server all justify a backup; a malformed request of ours does not, because it would fail there too. One gap remains, and I have left it visible: the 400 for your own spend limit is still a plain BadRequest. Recognising it would mean matching
the wording of its message, which vendors change.
Counting Cost
You cannot control what you do not measure. After every successful call the bot records the tokens used, the model that answered, which provider served it, the user, and the cost. The price is data you supply, per million tokens, keyed by the model name the provider reports back:
If the model's name is not in your price table, the cost is recorded as unknown, not zero, and an unpriced:<model> event is raised. That matters because vendors report versioned model names and a typo or a new
version would otherwise make a bill look cheaper than it is. The storage and the report query:
A worked example, by hand and by code, with example prices (see Exercise 1):
Two honest limits of this meter. It uses the plain input and output token counts a provider reports, so it ignores cache pricing: Anthropic's page says cached input tokens are billed at a cache-read rate and (for most models) are not counted toward input limits, which this simple meter does not model. And the numbers are only as good as the usage the provider reports.
Limits: Rate, Per-User Budget, Global Budget
Three limits, checked in this order before any model call, each with a fixed polite reply and an event:
| Limit | Protects against | How |
|---|---|---|
| Rate limit per user | Floods, scripts, double-sends | A token bucket in memory: a burst of burst messages, then per_minute sustained. The reply says how many seconds to wait. |
| Daily tokens per user | One person using all of the budget | Sum of the user's recorded tokens over a rolling 24 hours |
| Daily spend for the whole bot | A surprise bill | Sum of recorded cost over a rolling 24 hours |
The checks and the metering inside Bot (the rest of the class is unchanged from chapter 6):
A simulated session (times are simulated seconds, prices are examples, the daily limits are deliberately tiny so every limit is reached):
Read it as a story. Ada's fourth message in three seconds hits the rate limit and is told to wait 7 seconds. Later she is under the rate limit but over her daily token budget. Her next two messages meet the rate limit again, because the bucket does not refill while she keeps trying. Bob is a different user with his own bucket and budget, so his first message works. His second hits the global daily spend limit, which Ada's usage and his own first message had used up. The report at the bottom is the whole day in one table.
Three caveats. The budget is checked before the call and recorded after, so a user can overshoot by one call (a strict limit would reserve the worst case in advance). The rolling window is "the last 24 hours", not a calendar day. And the rate-limit buckets live in memory: they reset when the program restarts, and a bot with several processes would need a shared store.
Retries: Backoff, Jitter and Retry-After
Many failures are brief: a rate limit that clears in a second, a server that hiccups. Retrying handles them, if it is done carefully. Retrying at once makes an overloaded service worse; retrying at fixed intervals makes every client that failed together
come back together. The standard fix is exponential backoff with jitter: wait longer after each failure (doubling, up to a cap), and make each wait a random fraction of that so clients spread out. The version here is "full jitter":
the wait before the next attempt is a random number between 0 and min(max_delay, base_delay ร 2nโ1).
Rules the code follows: only retryable errors are retried; a server's Retry-After is obeyed, unless it is longer than max_delay, in which case giving up at once beats keeping a person waiting for minutes;
and the number of attempts is bounded. Five scenarios with a seeded random source (the waits are random but repeatable):
Note the first line: the second wait is only 0.03 seconds. Full jitter can produce a very short wait; that is by design, since the point is to spread clients out, not to guarantee a minimum.
Does jitter really help? I simulated 200 clients that all fail at the same instant and counted how many retries land in any single 0.1-second slot (this is my own small simulation, not a measurement of a real service):
Without jitter all 200 retries arrive together, at every attempt. With full jitter the worst slot holds 49, then 27, then 15, because the cap doubles each time and the retries spread over a wider window. That is the thundering-herd effect that jitter removes, and it matches the AWS article's conclusion that jitter spreads retries into a roughly constant rate of calls.
- A timeout does not mean the request failed. The provider may have processed (and billed) it while the answer was lost. A retry can therefore cost twice. For a chat reply that is usually acceptable; for anything with side effects it is not, and later chapters on tools would need idempotency.
- Total waiting time is not capped. Three attempts with a 60-second timeout each could keep a person waiting three minutes. For a chat, lower the provider timeout (for example to 20 seconds) or add an overall deadline. The code above does not have a deadline.
The Circuit Breaker: Stop Hammering What Is Down
If the provider is down, retrying every request wastes time for everyone: each user waits through the full retry cycle just to learn what the bot could already know. A circuit breaker remembers recent failures.
After failure_threshold failures in a row it opens: further calls fail instantly (with CircuitOpen) for reset_after seconds. Then it goes half-open and lets exactly one trial call through: success closes it,
failure opens it again for another wait. Only failures that could be the provider's fault count; our own bad requests do not.
Combined with a backup provider (next section), the timeline looks like this. The primary fails four times, so the breaker opens after two requests; for the next minute the primary is not even tried; at 40 seconds the one trial succeeds and the breaker closes (times are simulated):
Look at the "primary calls so far" column: it stops growing while the breaker is open (4 calls from 5 s to 20 s) and starts again at the trial. The fallback log at the bottom shows the first two failures were real server errors and the rest were fast
CircuitOpen rejections.
A Backup Provider
The last layer is a second provider. FallbackProvider tries each provider in order and moves on when the error says a backup could help (fallback_ok); it records which provider failed and why, and stamps the reply with served_by
so the meter and the logs know who answered. The usual stack wraps each provider in retry, then breaker, then puts the list in the fallback:
A 12-request timeline for exactly that stack (Exercise 3 asks you to write it): the primary fails on its third to eighth calls and works otherwise. The backup answers during the trouble; the breaker opens, a trial at 40 s fails and reopens it, and the next trial at 60 s succeeds and hands traffic back to the primary:
The backup is a different model, so its answers may differ from the primary's in tone, length and following the persona. Run the scenarios from chapter 5 against the backup too, and give it a price entry: the meter keys prices by the model name each reply reports. A cheaper model that you route simple messages to is the same idea with a different trigger (message length, or a classifier) instead of failure; I have not built that.
- An off-by-one in the budget window. A test advanced the clock by exactly 24 hours and expected the user's old usage to have expired, but it had not: my query counted records with
at >= since, so a record exactly 24 hours old was still inside the window. The fix wasat > since. Boundary conditions deserve their own test; this one was found only because the test used the exact boundary. - A test that expected exactly one event. My rate-limit test asserted the event list was exactly
[rate_limited], but the fake's replies named a model with no price, so "unpriced" events were in the list too. The code was right and the assertion too strict; the assertion now filters for the event it is about. Over-specific assertions break when unrelated features are added. - A demo that did not show what it claimed. My first breaker timeline never showed the breaker closing again, because the scripted failures lasted too long, and my first limits demo never reached the global budget. I adjusted the scripts until each run showed the whole life cycle. A demonstration should be checked against the claim it illustrates, not just run.
What Is Still Missing
- Streaming. A reply that fails halfway has already shown the user some text; retry and metering need extra care there (chapter 8 touches this).
- Cache-aware cost. See above: cached tokens are priced differently.
- Reading the provider's rate-limit headers. Anthropic returns headers such as
anthropic-ratelimit-requests-remainingandanthropic-ratelimit-tokens-reset. A smarter bot would slow itself down before getting a 429; this one reacts afterwards. - Real latency. Everything here ran against fakes and a local server, so I have no real timing numbers.
Hands-On Exercises
With two example prices and three scripted calls of known token counts, work out the cost of each call and the total by hand (in code, with cost_of or plain arithmetic), then run the same calls through a Bot with Limits(prices=...) and show that bot.report() gives the same total.
Predict the waits RetryingProvider will make in four situations (use rng=lambda: 1.0 so the jitter always picks the longest allowed wait): five failures with the default settings, five failures with a base of 1.0 and a cap of 3, a Retry-After: 2 followed by a server error, and max_attempts=1. Then run them and compare.
Build FallbackProvider([BreakerProvider(RetryingProvider(primary)), RetryingProvider(backup)]) with a fake clock, make the primary fail on its 3rd to 8th calls, send 12 requests five seconds apart, and print for each: the breaker state, who answered and how many times the primary has been called. Explain every row where the primary was not tried.
Chapter 7 Quick Reference
- Provider facts (Anthropic): limits in requests, input tokens and output tokens per minute, token bucket, bursts can 429; 429 has
retry-after; the monthly spend-cap 429 has none and keeps failing (enforced_spend_limit_reached); your own spend limit gives a 400 - Errors have
retryable(retry the same provider) andfallback_ok(try another);QuotaExhausted: not retryable, fallback ok - Cost:
Price(input_per_mtok, output_per_mtok)keyed by the model name in the reply; unknown model means cost unknown plus anunpriced:event;usagetable;bot.report() - Limits (checked before the call, recorded after): token-bucket rate limit per user, rolling-24-hour tokens per user, rolling-24-hour cost for the bot; a user can overshoot by one call
- Retry: only retryable errors; wait = random(0, min(max_delay, base ร 2^(nโ1))) (full jitter); obey
Retry-Afterunless it exceedsmax_delay; bounded attempts; no overall deadline - Breaker: closed, then open after N failures in a row, half-open after
reset_afterwith one trial; only the provider's faults count;CircuitOpenis a retryable unavailable error - Stack:
FallbackProvider([BreakerProvider(RetryingProvider(primary)), RetryingProvider(backup)]); reply carriesserved_by; test the persona on the backup too - Boundary conditions (
>versus>=) need tests; a timeout does not mean the request did not happen