A solo backend rarely dies from a missing feature. It dies from a polite retry loop that keeps calling a paid path after a free allowance is gone, and the invoice arrives before the bug report does.
The fix is not a bigger free tier. It is a degradation ladder: every capability declares a free rung, a capped rung, and a truthful user-visible result for the capped rung. The app then loses features in a known order instead of losing your weekend to a billing console.
The artifact below is about sixty lines of standard-library Python: a policy table, a rung selector, a test that proves a sealed capability never touches the network, and a local rehearsal command that simulates a cap without burning real allowance. It targets one-person projects that must ship this week. It is not a quota system, and it will not turn a limited plan into an unlimited product.
Much of the recent AI-agent debate on DEV, including the argument that most agents are branching control flow in disguise, is orthogonal to a solo product. If the loop really is control flow, then control flow is exactly where the budget policy belongs.
The failure mode, stated precisely
Free tiers usually fail in three ways: a hard cap on requests or units, a rate limit with a retry window, and a quiet change to the terms. Only the first is visible from inside the code.
Model-written handlers make all three worse. Generated retry logic often falls back to the paid endpoint "just once", and that single fallback is where a zero-cost plan ends. A ladder removes the decision from the retry path and moves it into a table a human can review in one sitting.
The three-rung model
Three rungs are enough for an indie MVP.
- FULL — the capability runs as designed on the free path.
- REDUCED — the capability runs with cheaper input: shorter context, a cached answer, a smaller batch.
- SEALED — the capability refuses, returning a prepared message and a pointer to a free alternative.
SEALED carries the weight. A sealed capability must never return a fabricated result, and it must never open a socket. If a degraded answer would mislead the user, sealing is correct even when a reduced path is technically possible.
Step 1 — Declare capabilities, not vendors
Policy should name what the user wants, not which service provides it. Provider names belong in configuration, because they are the part that changes without notice.
# ladder.py — standard library only, runs as-is on Python 3.10+
from __future__ import annotations
import os
from dataclasses import dataclass
from enum import IntEnum
class Rung(IntEnum):
FULL = 0
REDUCED = 1
SEALED = 2
@dataclass(frozen=True)
class Capability:
name: str
free_rung: Rung
capped_rung: Rung
sealed_message: str
POLICY: dict[str, Capability] = {
"answer.question": Capability(
"answer.question", Rung.FULL, Rung.REDUCED,
"Answers are paused while the free allowance refills.",
),
"summarize.doc": Capability(
"summarize.doc", Rung.FULL, Rung.SEALED,
"Summaries need a paid path; they are disabled on this plan.",
),
}
Two entries are enough to start. Each new capability added later forces the same three questions: what is free, what happens when free runs out, and what does the user actually read.
Step 2 — Make the ladder the only entry point
Nothing in the app should call a paid or free endpoint directly. Everything goes through one selector, so the cap logic exists in a single place that can be tested.
class Ladder:
"""Picks a rung per call. Units are your own accounting, not vendor quota."""
def __init__(self, units_left: int, policy: dict[str, Capability] | None = None) -> None:
self.units_left = units_left
self.policy = policy or POLICY
def rung_for(self, capability: str) -> Rung:
cap = self.policy[capability]
return cap.free_rung if self.units_left > 0 else cap.capped_rung
def run(self, capability: str, full_path, reduced_path=None, cost: int = 1):
cap = self.policy[capability]
rung = self.rung_for(capability)
if rung is Rung.FULL:
self.units_left -= cost
return full_path(), rung
if rung is Rung.REDUCED and reduced_path is not None:
return reduced_path(), rung
return cap.sealed_message, Rung.SEALED
The reduced path deliberately costs nothing in this model. That is a design choice, not a measurement: a cached or truncated answer that still hits a metered endpoint should decrement units_left too. Keep the accounting conservative, and the worst case is an early seal rather than a surprise charge.
Step 3 — Prove the seal in a test
The seal is the only rung that protects both the user and the bill, so it deserves an explicit assertion. The test below fails if a capped capability reaches either implementation.
# test_ladder.py — pytest
from ladder import Ladder, Rung, POLICY
def test_capped_capability_calls_nothing():
calls = []
ladder = Ladder(units_left=0)
value, rung = ladder.run(
"summarize.doc",
full_path=lambda: calls.append("full"),
reduced_path=lambda: calls.append("reduced"),
)
assert rung is Rung.SEALED
assert calls == []
assert value == POLICY["summarize.doc"].sealed_message
Run it with pytest -q test_ladder.py. A second assertion worth adding later checks that every capability in POLICY has a non-empty sealed_message, which prevents the most common shipping mistake: a correct refusal that says nothing useful.
Step 4 — Rehearse the cap locally
Simulating a cap should not require waiting for a real one. The demo entry point reads the unit count from the environment so the transition is reachable in one command.
if __name__ == "__main__":
ladder = Ladder(units_left=int(os.environ.get("UNITS_LEFT", "2")))
for _ in range(4):
value, rung = ladder.run(
"answer.question",
full_path=lambda: "full answer",
reduced_path=lambda: "short answer",
)
print(f"{rung.name:8} {value!r} units_left={ladder.units_left}")
Expected output, deterministic and offline:
$ python ladder.py
FULL 'full answer' units_left=1
FULL 'full answer' units_left=0
REDUCED 'short answer' units_left=0
REDUCED 'short answer' units_left=0
UNITS_LEFT=0 python ladder.py jumps straight to the capped rung. Wiring that environment variable into the nightly CI job means every future commit is exercised against the capped state, not just the happy path.
Step 5 — Keep the repair loop on free compute
The ladder protects runtime behavior. Something still has to write the reduced paths and the sealed messages, and that authoring pass is itself a model loop with a cost. Running it on free model access and a free server option keeps the authoring pass from consuming the same budget the ladder is protecting.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The operator states that free model access and a free server option are available; plan terms change, so confirm the current details on the project before depending on them for anything scheduled. For a solo builder, the practical pattern is to do the ladder work in one short session on free compute, then commit the policy table as reviewed code that no unattended agent may edit.
Decision table
| Capability | Full path cost | Capped rung | User-visible result | Acceptable for a paid user? |
|---|---|---|---|---|
| Answer a question | 1 unit | Reduced, free, cached | "Short answer, may be dated" | Yes |
| Summarize a document | 1 unit | Sealed | "Disabled on this plan" | Yes |
| Search the web | 1 unit | Sealed | "Search unavailable; paste a link" | Yes |
| Send a notification | 0 units | Reduced | "Digest instead of instant" | Yes |
Fill the last column honestly. If the answer is "no", the capability should not exist on the free build at all.
What breaks anyway
Caps can arrive mid-request, so the rung must be evaluated per call rather than once at process start. Long-running workers that cached a rung in memory will keep using it after the allowance is gone.
Local unit accounting drifts from the provider's accounting. Treat units_left as a conservative estimate, refresh it from the provider where an API allows it, and never treat it as authoritative.
Sealed messages are a product surface. A message that implies the feature is broken rather than paused generates support mail that costs more time than the tokens saved.
Finally, the policy table is a review boundary. Letting an unattended agent add capabilities and sealed messages is how a truthful refusal becomes a confident wrong answer.
Who should not use this
Projects with legal or safety obligations, such as anything resembling medical, legal, or financial advice, should not serve degraded output at all; either the full path is available or the request should fail loudly. Teams with a funded budget should skip the ladder entirely and pay for the correct path, because degradation adds a branch to every capability.
Anyone whose free tier is genuinely unlimited does not need a capped rung either, though the seal test remains a cheap way to prove that no capability silently reaches a paid endpoint.
A rung table plus one assertion is roughly an hour of work. It converts an unpredictable bill into a list of features your users can see disappearing in a known order, which is a trade most solo products should take.
Top comments (0)