DEV Community

Dakota Wu
Dakota Wu

Posted on

A Degradation Ladder for Solo Backends: Ship This Week, Seal Before You Pay

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.

  1. FULL — the capability runs as designed on the free path.
  2. REDUCED — the capability runs with cheaper input: shorter context, a cached answer, a smaller batch.
  3. 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.",
    ),
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)