DEV Community

kongkong
kongkong

Posted on

A Free Tier Is a Contract Test, Not a Backend

Last Tuesday a teammate opened a pull request that called a model straight from a route handler, and on his laptop it worked for two days without a hiccup. On staging it died at 03:14 with a 500 that was hiding a 429 inside three layers of try/catch, and nobody could tell which layer had decided to give up. I have watched this same failure land in four different codebases this year, and the model is never the culprit. The culprit is that the free tier made the first call so easy that he skipped the seam entirely.

Here is my position, stated plainly, because I do not think this deserves a polite hedge: a free model tier is not a cheaper backend, it is the cheapest contract test you will ever be handed. If you spend it avoiding an adapter, you have purchased a small discount plus a large future incident. If you spend it proving that your adapter's failure taxonomy survives a real, flaky, rate-limited upstream, you have purchased something that outlives every model name in this week's news cycle.

That distinction matters more now than it did a year ago, because the industry has shipped agent features faster than it has defined what an agent failure looks like. We have plenty of demos where the happy path is beautiful, and very few where a rate limit arrives mid-request and the UI tells the truth about it. So when I look at MonkeyCode — an open-source project offering free model access and a free server option — I judge it on one question only. Can it help me run the failure tests I keep postponing?

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

One honesty note before the code: the free token allowance and the free server option are operator-supplied claims about what is available at the time of writing. Free tiers move, so read the project's current terms before you plan a workflow around them, and never treat any free tier as a capacity guarantee.

The seam I wish my teammate had written first

A provider seam is boring on purpose. It is one interface, one normalized failure type, and one rule that says failures come back as values instead of exceptions thrown from a request handler. The rule sounds pedantic until you meet a 429 that has been re-thrown three times and renamed twice.

// src/ai/contract.ts — UNTESTED SKETCH. Illustrative shape, not a product API.
export type Failure =
  | { kind: "rate_limited"; retryAfterMs?: number }
  | { kind: "timeout" }
  | { kind: "cancelled" }
  | { kind: "upstream"; status: number; requestId?: string }
  | { kind: "invalid_output"; detail: string };

export type Completion = {
  text: string;
  tokensIn: number;
  tokensOut: number;
  requestId: string;
};

export interface ModelProvider {
  readonly name: string;
  complete(input: { prompt: string; signal?: AbortSignal }): Promise<Completion | Failure>;
}
Enter fullscreen mode Exit fullscreen mode

The adapter for a free endpoint looks almost identical to the adapter for a paid one, which is the entire point. Below I am guessing at the HTTP shape, so treat the path and body as placeholders you replace with whatever the docs actually say today.

// src/ai/providers/monkeycode.ts — UNTESTED SKETCH.
import type { Completion, Failure, ModelProvider } from "../contract";

const BASE = process.env.MONKEYCODE_BASE_URL ?? "";
const KEY = process.env.MONKEYCODE_API_KEY ?? "";

export function monkeycodeProvider(): ModelProvider {
  return {
    name: "monkeycode",
    async complete({ prompt, signal }): Promise<Completion | Failure> {
      // Fail closed: a missing key is a configuration error, not a user error.
      if (!BASE || !KEY) {
        return { kind: "upstream", status: 500, requestId: "missing-config" };
      }
      try {
        const res = await fetch(`${BASE}/complete`, {
          method: "POST",
          signal,
          headers: { "content-type": "application/json", authorization: `Bearer ${KEY}` },
          body: JSON.stringify({ prompt }),
        });
        if (res.status === 429) {
          const raw = Number(res.headers.get("retry-after") ?? NaN);
          return { kind: "rate_limited", retryAfterMs: Number.isFinite(raw) ? raw * 1000 : undefined };
        }
        if (!res.ok) {
          return { kind: "upstream", status: res.status, requestId: res.headers.get("x-request-id") ?? undefined };
        }
        const body = await res.json();
        return {
          text: body.text,
          tokensIn: body.usage?.input_tokens ?? -1,
          tokensOut: body.usage?.output_tokens ?? -1,
          requestId: body.request_id ?? "unknown",
        };
      } catch (err) {
        if ((err as Error).name === "AbortError") return { kind: "cancelled" };
        return { kind: "timeout" };
      }
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

One suite, every adapter, including the deployed one

The suite matters more than the adapters, because it is the thing you cannot fake. I keep it provider-agnostic and run it against a registry, so adding a second backend is a one-line change rather than a new test file nobody maintains.

// tests/provider.conformance.ts — UNTESTED SKETCH.
import { describe, expect, it } from "vitest";
import { providers } from "../src/ai/registry";

for (const [name, make] of Object.entries(providers)) {
  describe(`provider contract: ${name}`, () => {
    it("returns cancelled, not timeout, when the signal is already aborted", async () => {
      const ac = new AbortController();
      ac.abort();
      const result = await make().complete({ prompt: "hello", signal: ac.signal });
      expect(result).toMatchObject({ kind: "cancelled" });
    });

    it("never throws — every failure is a value with a kind", async () => {
      const result = await make().complete({ prompt: "" });
      expect(result).toHaveProperty("kind");
    });

    it("reports token usage as finite numbers, or -1 when unknown", async () => {
      const result = await make().complete({ prompt: "one short sentence" });
      if ("kind" in result) return; // provider outage is asserted elsewhere
      expect(Number.isFinite(result.tokensIn)).toBe(true);
      expect(Number.isFinite(result.tokensOut)).toBe(true);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Here is the part I actually care about, and the reason a free server option is worth more than a free key. You point the same suite at a deployed instance, not at localhost, and you find out whether your environment wiring is real.

# Local adapters, real network calls, no bill.
npx vitest run tests/provider.conformance.ts

# Same suite, deployed target: catches missing env vars and health-check gaps.
MONKEYCODE_BASE_URL="$DEPLOYED_BASE_URL" \
MONKEYCODE_API_KEY="$KEY" \
PROVIDER_TARGET=deployed \
  npx vitest run tests/provider.conformance.ts
Enter fullscreen mode Exit fullscreen mode

A green local run and a red deployed run is the single most useful signal I get out of a free environment. It means the bug lives in deployment, permissions, or configuration rather than in the model call, and that is a much smaller search space than the one my teammate had at 03:14.

The mapping table that ends the 429-versus-500 argument

Most cross-layer arguments are really disagreements about which layer owns a translation. I settle them with a table, checked into the repository next to the adapter, so nobody has to remember the decision at review time.

Upstream condition Adapter result Response to the browser Retried?
HTTP 429 rate_limited 429 with Retry-After Yes, once, honoring the header
Signal aborted cancelled 499-style client abort log No
5xx from upstream upstream with request id 502, request id echoed Yes, only idempotent reads
Empty or unparseable body invalid_output 502, never a green toast No
Missing API key upstream 500 503, pages the operator No

The row that saves the most pain is the last one. A missing key is not a model problem, and pretending otherwise sends everyone hunting through prompt code for an hour. Fail closed, return a status that says "we are misconfigured," and keep the model out of the blame list.

What actually broke when I ran this pattern

Three failures showed up in the first week, and none of them were the model's fault. Cancellation stopped at the adapter because the route handler never forwarded request.signal, so closing a browser tab kept burning tokens silently. Token accounting drifted because my adapter returned -1 for unknown usage and the cost dashboard happily summed negative numbers. And a retry policy I copied from a payment integration retried a non-idempotent write, which produced two saved summaries and one very confused user.

None of those are exotic. They are the ordinary consequences of letting a working first call convince you that the seam is unnecessary.

Who should not use this approach

If your prompts cannot leave your network without a signed agreement in place, a hosted free tier is the wrong environment regardless of price, and no adapter will fix that. If you are already at committed-volume scale, free capacity is irrelevant and a dedicated deployment with a documented throughput target is the honest choice. And if you are tempted to let a free server hold the only copy of user state, do not — run it as a staging target with disposable data, then move persistence somewhere you control.

For everyone else, the pattern is cheap and the payoff is durable. Write the contract, run the suite against something you did not pay for, watch which layer turns red, and fix that layer instead of re-prompting. If you want a place to try it without a bill, the free model access and the free server from MonkeyCode are worth one afternoon of your time.

So here is the question I would like answered in the comments, because it is the one I cannot answer alone. Which layer handoff in your stack is least stable when an upstream starts misbehaving — and what concrete failure state or response code does it hand you when it breaks? A specific status code and the layer that produced it teaches me more than a general warning ever will.

Top comments (0)