One Bug, Ten Calls, Zero Answers
A duplicated API call burned a whole search quota before a single result came back. How to build budget guards into iOS apps on metered AI pricing.
On 31 August 2026, a research session for this blog spent its entire search quota and came back with nothing. The cause was one line of wrapper code that called the API twice: once inside a type check, once for the return value. Every intended search became two actual searches, and the quota ran out before the first useful result arrived.
The bug is six lines long
The shape of it is dull, which is why it ships. A wrapper function receives a result, checks whether it’s the type it expects, and returns it. The check calls the API. The return calls it again.
In Swift, the same bug wears a different hat. A computed property that performs work, read twice in one view:
if viewModel.results.isEmpty { // performs the fetch
EmptyStateView()
} else {
ForEach(viewModel.results) { … } // performs it again
}
Nothing is misspelled. The types line up. The compiler has no opinion. The only symptom is that your bill and your quota move twice as fast as your feature does.
In the 31 August session, five intended searches became ten actual calls, the tenth returned max_uses_exceeded, and every retry after that returned the same. Zero sources retrieved. The searches were batched three per call, so when the cap hit, the whole batch failed — including the two queries that had never been attempted. Batching improves throughput and worsens blast radius.
Where duplicate calls come from in iOS code
The pattern isn’t exotic. It has a dozen native habitats in a SwiftUI codebase, and most of them are things you’d write on purpose.
- A
Task { }started from insidebody.bodyis evaluated an unspecified number of times. .task(id:)re-firing while an.onChange(of:)handler kicks off the same fetch.onAppearfiring twice underNavigationStack,TabView, or view re-parenting.- A Combine publisher without
.share(), where each subscriber re-triggers the upstream request. - A client timeout shorter than the server’s completion time, so the retry fires while the original request is still billing.
- Pull-to-refresh,
.refreshable, and a poll timer all landing in the same second.
What these have in common is that they are invisible in output. The feature works. The results look right. You cannot catch this from the provider’s dashboard either — usage graphs lag, and max_uses_exceeded arrives well before the billing chart moves. By the time the number is visible, the quota is gone.
What a duplicated call actually costs
Start with metered search, because the market for it got worse in 2026. Brave removed its free tier — previously 2,000 free queries a month, raised to 5,000 under the August 2025 AI Grounding update. Developers now get $5 in monthly credits, roughly 1,000 searches, after which the card on file is charged. Current rates: Search at $5 per 1,000 requests, Answers at $4 per 1,000 queries plus $5 per million tokens, Spellcheck and Autosuggest at $5 per 10,000 each. (These are as reported by implicator.ai on 8 June 2026; Brave’s own earlier “from $3 per 1,000” figure is superseded. Verify against Brave directly before building a business case on it.) The same report notes this arrived roughly six months after Microsoft shut down the Bing Search API — a claim I could only source secondhand, so treat it as colour.
At $0.005 a search, the 31 August incident cost five cents for zero results. Cost per useful result: undefined. The money is trivial; the outcome is total. That asymmetry is the reason to care.
Now scale it. Take a hypothetical app with 10,000 daily active users making three searches each, with every call duplicated. That’s 30,000 extra queries a day, $150 a day, $54,750 a year, buying nothing. That is arithmetic on a made-up traffic number, not a measurement — but the multiplication is the point, and the multiplier is 2.
Tokens behave differently. On the one rate corroborated by two independent trackers — OpenAI’s gpt-5.6-sol at $5.00 per million input tokens, $0.50 cached, $30.00 per million output — a 2,000-in / 500-out request costs $0.025, or $0.05 duplicated. Output is six times input. A duplicated generation hurts far more than a duplicated retrieval, and cached input at a tenth of the price means coalescing and caching are separate wins. Two trackers disagree by up to 5× on the mid- and low-tier models, so I’m quoting only the flagship.
Single-flight coalescing makes the bug harmless
You cannot audit every read of every property forever. What you can do is make the second call join the first instead of starting a new one.
actor SingleFlight<Key: Hashable & Sendable, Value: Sendable> {
private var inFlight: [Key: Task<Value, Error>] = [:]
func run(_ key: Key,
operation: @Sendable @escaping () async throws -> Value) async throws -> Value {
if let existing = inFlight[key] {
return try await existing.value // join the existing call — zero extra spend
}
let task = Task { try await operation() }
inFlight[key] = task
defer { inFlight[key] = nil }
return try await task.value
}
}
At the call site, the duplicate-read bug becomes a non-event:
let results = try await flight.run(query) { try await api.search(query) }
Two honest caveats. The entry clears when the first caller resumes, so an arrival a moment later starts a fresh flight — usually correct, but it is a choice you’re making. And cancellation is the sharp edge: if the first caller cancels, everyone who joined inherits the cancellation. Whether you want that depends on whether your provider bills on request or on completion, which varies. There is no universally right answer, only a documented one.
One process note: this sketch, and the ones below, are written against APIs stable since Swift 5.5 / iOS 15, but I have not compiled them, and the current strict-concurrency spelling (@Sendable @escaping versus sending, and default actor isolation under Swift 6.3.3, tagged 30 June 2026) is the part I’d check against the toolchain before pasting it anywhere.
A counter you can see, and a budget with a terminal state
The 31 August bug was invisible in the output and would have been obvious in a counter. So put the counter on the screen in debug builds.
@MainActor @Observable
final class APIMeter {
private(set) var calls = 0
private(set) var coalesced = 0
private(set) var byEndpoint: [String: Int] = [:]
func record(_ endpoint: String) { calls += 1; byEndpoint[endpoint, default: 0] += 1 }
func recordCoalesced() { coalesced += 1 }
}
Render it as a small overlay — "\(meter.calls) calls · \(meter.coalesced) saved" in the top-trailing corner, behind #if DEBUG, with allowsHitTesting(false). A climbing number in the simulator’s corner turns “why is this slow” into “why did that tap cost four calls.” Pair it with an os.Logger signpost interval per request so Instruments shows overlapping flights visually.
Then add a budget, and give it an explicit terminal state rather than a bool:
enum BudgetVerdict: Sendable {
case allow(remaining: Int)
case exhaustedSession
case exhaustedDaily
}
Back it with an actor holding per-session and per-day counters, resetting the daily count when Calendar.current.startOfDay(for:) changes. Two details matter more than the code. Consume the budget at the single-flight boundary, not at the call site, or coalesced joiners each decrement it and you’ve priced your own optimisation out of existence. And the device clock is user-settable — a local daily reset is a courtesy limit, not a security control. In-memory counters also reset on cold start. Client-side counters are observability; only a server-side proxy is enforcement. Both are worth having, for different reasons.
The 429 that will never succeed
This is the most actionable finding in the notes, and the one most retry code gets wrong. Per Anthropic’s error documentation, a 429 rate_limit_error can mean a per-minute rate limit, a monthly spend cap, or a workspace spend limit — three very different situations behind one status code.
A rate-limit 429 carries a retry-after header in seconds and should be honoured. A tier spend-cap 429 has no retry-after and keeps failing until access resumes. Retrying it is pure waste. Sort your errors into three classes:
- Retryable with backoff — 529
overloaded_error, 500, 504. - Retryable after a stated delay — 429 with
retry-after. - Terminal until a human or the calendar fixes it — spend-cap 429, 402
billing_error.
Blind exponential backoff on class three converts a budget failure into a battery-and-data failure on your user’s phone. The 31 August max_uses_exceeded was class three: no retry-after, no degradation, the capability simply stopped existing. Every retry after the first was guaranteed to fail, and did.
One more trap: 529 during a streaming response arrives as an error event after a 200 status. Code that inspects only the HTTP status silently treats total failure as success.
On-device is now a real budget strategy
The cheapest guard is not calling the metered API at all. As of iOS 26, the Foundation Models framework exposes the on-device Apple Intelligence model: no API key, works offline, no per-call charge, structured output via @Generable, available across iOS, macOS, iPadOS and visionOS. The on-device model is roughly 3B parameters — a hard ceiling, not a frontier substitute. Apple Intelligence requires iPhone 15 Pro or later, while iOS 26 itself runs back to iPhone 11 and the second-generation SE, so the fallback isn’t universal.
WWDC 2026 loosened this further. Session 339 introduced a public protocol layer: third parties can ship a Swift package implementing the LanguageModel protocol, drop-in compatible with any Foundation Models app. Core AI lets developers ship third-party open-source models — one write-up cites 70B-parameter LLMs — running locally on Apple Silicon at no server cost, with Hugging Face MLX-community models loadable directly. Worth noting the security angle: each shipped model is a binary artifact, and model weights deserve the same provenance scrutiny as any other dependency.
The practical consequence is that budget guarding becomes a routing decision. Classification, extraction and short summarisation go on-device for nothing. Metered calls are reserved for what genuinely needs frontier capability. A guard with an on-device fallback can degrade in a way that is different rather than broken.
All of the above is pegged to iOS 26.6, released 27 July 2026. iOS 27 is in development and expected this fall, so re-check anything version-specific after mid-September.
Loudly or quietly — decide before 2am
When the budget is gone, the feature has to do something. There’s a real argument on both sides and no authority that settles it.
Loudly. Show “you’ve used today’s 20 requests, resets at midnight,” with the count visible before exhaustion. Users forgive limits they can see and resent capabilities that mysteriously get worse. A visible counter doubles as a conversion surface — and Apple’s App Review Guidelines, updated 8 June 2026, explicitly contemplate this: “Subscriptions may include consumable credits, gems, in-game currencies, etc.”, and you may offer subscriptions that grant discounted consumables. “Sometimes it just doesn’t work” is an unbounded support cost.
Quietly. Fall back to cache, local search, or the on-device path and say little. Most users have no mental model of API credits, and a quota dialog exports your billing problem to them. With a credible on-device fallback the result is different, not broken, so an alert actively misleads. Loud limits also invite gaming and screenshot outrage.
What both camps agree on, and where I’d put the effort:
- Decide before the budget runs out, not in a hotfix at 2am.
- Make the degraded state reachable on demand in debug builds — a toggle that forces “budget exhausted” so the path gets tested, screenshotted and reviewed like any other state. Untested error states are the actual failure.
- Never degrade into an infinite spinner. Nobody chooses that and everybody ships it.
Two compliance notes if you charge for this. Apple Developer News published within the last week reminds developers that AI assistants and chatbot functionality affect sensitive-content frequency and age rating, referencing a 31 January 2026 deadline for the updated age-rating questions; apps remain subject to the objectionable-content and UGC guidelines, plus COPPA and GDPR. And whether a bring-your-own-API-key model is permitted, and how guideline 3.1.1 applies when the user pays the provider directly, I did not research and will not guess at.
Which brings this back to 31 August. The session that produced these notes also hit its own cap: nine searches completed, the tenth batch refused, three planned verifications never run. I have flagged those as unverified rather than filling them in from memory — which is why the Swift concurrency annotations above carry a caveat and the mid-tier token prices don’t appear at all. That is the same discipline a budget guard needs. When the calls run out, the honest move is to say what you don’t have, not to invent something plausible to cover the gap.