May 17, 2026
How We Built the Hosted-Mock Cost Cap
A week before we opened MockForge Cloud to paid customers, a launch-readiness review surfaced a problem we should have caught earlier: a buggy or hostile customer on the $29 Pro plan could DOS their own mock indefinitely and hand us a $333/month Fly compute bill. Ten times the customer's plan price, on a single mistake. This is how we fixed it.
The math
Each hosted mock runs on a Fly shared-cpu-1x machine — small, but at about $0.152 per running hour. Fly auto-stops idle machines, so the typical mock costs us almost nothing when nobody's using it. The problem is "nobody's using it" gets decided by request traffic. If a customer's misbehaving client keeps the machine warm 24/7, that's 730 hours × $0.152 = $111 per machine per month. Pro allows three concurrent mocks. Three machines, kept hot by a single broken client, becomes $333.
The customer doesn't need to be hostile for this to happen. A test in their CI that retries forever on a 500. A frontend dev loop that polls every 100ms. A bot that decided our subdomain looked interesting. Any of those keep the machine alive.
What the proxy looked like
The registry server has a wildcard proxy at <slug>.mocks.mockforge.dev that routes each request to the right Fly machine. Two issues with the existing code:
// Read body, no cap.
let body_bytes = axum::body::to_bytes(body, usize::MAX).await?;
usize::MAX. A single uploaded blob could be hundreds of megabytes through us before Fly received any of it. And there was no per-deployment rate limit — every request was forwarded.
The fix
Three changes:
1. Body-size cap, configurable. Default 10 MiB. Override via MOCKFORGE_HOSTED_MOCK_MAX_BODY_BYTES. Returns 413 Payload Too Large when exceeded. Five lines of change in the proxy.
2. Per-deployment RPS rate limit. An in-memory token bucket keyed on deployment UUID. 1-second rolling window, 100 RPS default, overridable via env. Returns 429 with a Retry-After header. The implementation is ~100 lines including tests:
pub fn check(&self, deployment_id: Uuid) -> Result<(), u64> {
let mut state = self.state.lock()...;
let now = Instant::now();
let entry = state.entry(deployment_id).or_insert(WindowState {
window_start: now,
count: 0,
});
if now.duration_since(entry.window_start).as_secs() >= 1 {
entry.window_start = now;
entry.count = 0;
}
if entry.count >= self.rps_limit {
return Err(retry_after_secs);
}
entry.count += 1;
Ok(())
}
3. Block deploys for past-due subscriptions. If Stripe flips a subscription to past_due (payment retry in progress), we still serve existing mocks but reject new deploys until the bill clears. This is the harder edge case — the customer's still legitimate, just temporarily unpaid — but accruing more compute on their behalf while we can't bill them is the wrong default.
What we explicitly didn't ship
Three things were in the original spec and got deferred:
- Per-plan tuning of the RPS limit. Free 0, Pro 100, Team 1000 was the goal. The single 100-RPS default works for now; tiering needs a
limits_json.max_rpsmigration that didn't fit the timeline. - Multi-instance correctness. The limiter is in-memory and per-process. When the registry server scales beyond one Fly machine, we'll move state to the existing Redis pool. Single instance is fine for the first months of paid traffic.
- Fly spend alert. Pure ops work. Configure budget alerts in Fly's dashboard and wire the billing webhook to fire a notification at 2× plan-price spend. Not code we needed to write — just an operational checklist.
What we'd do differently
The whole class of "customer can cost us money through a feature we didn't gate" should have been an explicit checklist during the cloud-mode design, not something a launch-readiness review caught. The retroactive analysis was about an hour; the fix was a few hundred lines of code. The cost of not catching it would have been a real bill.
Lesson, mostly for ourselves: any cloud feature where a customer drives compute or bandwidth needs a default-on cap before launch, not after. Adding limits to a live system is a renegotiation with customers; shipping them in the default config is free.