DEV Community

kongkong
kongkong

Posted on

Put a Free Model Route Behind an Error Budget, Not a Retry Storm

429 is a signal, not a bug. A route that responds with 429 Too Many Requests is already telling the client to back off. A wrapper that immediately retries treats that signal as noise and spends remaining capacity faster. With the current wave of agent-driven workflows, one user action can fan out into many model calls, so a free model route is usually the first layer to saturate.

I used MonkeyCode's free model access and free server option as a disposable integration seam for this test. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I do not need a specific model name or quota to make the point: the problem shows up at any HTTP route that enforces rate limits.

The failure mode

Most integration wrappers start with a simple loop: call the endpoint, and if the status is 429 or 5xx, sleep and retry. That loop has three failure modes.

Signal What it means Naive reaction Breaker reaction
429 Quota or rate limit reached Retry immediately, spend more quota Open until Retry-After elapses
5xx Provider-side overload Retry and add more load Count against an error budget
Timeout Unknown state, maybe slow downstream Retry the same slow call Record as unknown failure, open at threshold

The worst case is a shared free route with many callers. A single process that retries on 429 can double or triple the original request volume before a human looks at the dashboard. If the caller is an agent loop, the same pattern can repeat for each tool invocation.

A small error-budget breaker

The breaker below reacts to three inputs: status code, Retry-After, and failure rate over a rolling window. It opens when either a 429 includes Retry-After, or the window contains enough samples and the failure rate crosses the configured limit. A half-open state sends one probe to test recovery.

from enum import Enum
import time

class State(Enum):
    CLOSED = 'closed'
    OPEN = 'open'
    HALF_OPEN = 'half_open'

class ErrorBudgetBreaker:
    def __init__(self, window_seconds=60.0, failure_rate_limit=0.20,
                 cooldown_seconds=30.0, min_samples=10):
        self.window_seconds = window_seconds
        self.failure_rate_limit = failure_rate_limit
        self.cooldown_seconds = cooldown_seconds
        self.min_samples = min_samples
        self.state = State.CLOSED
        self.window_start = time.monotonic()
        self.total = 0
        self.failures = 0
        self.opened_at = 0.0

    def _is_failure(self, status):
        return status is None or status == 429 or status >= 500

    def allow(self):
        if self.state == State.OPEN:
            if time.monotonic() - self.opened_at >= self.cooldown_seconds:
                self.state = State.HALF_OPEN
                return True
            return False
        return True

    def record(self, status, retry_after=None):
        now = time.monotonic()
        if now - self.window_start >= self.window_seconds:
            self.window_start = now
            self.total = 0
            self.failures = 0

        self.total += 1
        failed = self._is_failure(status)
        if failed:
            self.failures += 1

        if status == 429 and retry_after:
            self.state = State.OPEN
            self.opened_at = now - self.cooldown_seconds + retry_after
            return self.state

        if self.state == State.HALF_OPEN:
            if failed:
                self.state = State.OPEN
                self.opened_at = now
            else:
                self.state = State.CLOSED
                self.failures = 0
            return self.state

        if self.total >= self.min_samples and self.failures / self.total >= self.failure_rate_limit:
            self.state = State.OPEN
            self.opened_at = now

        return self.state
Enter fullscreen mode Exit fullscreen mode

A production wrapper calls allow() before sending a request and record() after receiving a response.

def parse_retry_after(value):
    try:
        return float(value)
    except (TypeError, ValueError):
        return None

def call_with_budget(client, url, payload, breaker, timeout=10.0):
    if not breaker.allow():
        return {'error': 'circuit_open', 'state': breaker.state.value}

    try:
        resp = client.post(url, json=payload, timeout=timeout)
    except Exception as exc:
        breaker.record(None)
        raise exc

    breaker.record(resp.status_code, parse_retry_after(resp.headers.get('Retry-After')))

    if resp.status_code == 429 or resp.status_code >= 500:
        return {'error': 'provider_unavailable', 'status': resp.status_code, 'state': breaker.state.value}

    resp.raise_for_status()
    return resp.json()
Enter fullscreen mode Exit fullscreen mode

Deterministic test plan

Rather than trusting a synthetic benchmark, I run the breaker against a deterministic sequence of statuses and check the state transitions. The table below uses the same code and parameters.

Input sequence min_samples failure_rate_limit cooldown Expected states
[200, 200, 429] 3 0.20 30s closed, closed, open
[200, 200, 200] 3 0.20 30s closed, closed, closed
[429 with Retry-After: 2s, 200] 5 0.20 30s open, skipped until header elapsed, then half-open
[500, 500, 500] 3 0.20 30s closed, closed, open

This is the minimum signal check I would want before wiring a new model route into a larger service. It verifies that the breaker sees rate limits differently from transient 5xx errors and that it does not stay open forever.

Where to place it

The breaker works best as a route-level seam shared by all callers to the same provider endpoint. If multiple workers use separate in-memory breakers, a noisy worker can still produce a retry storm while a quiet worker is open. In a single-process service, an in-memory instance is enough. In a multi-instance deployment, move the state to a small store or use coordinated half-open probes.

I keep the breaker outside the model call itself. The model code remains a simple HTTP call; the breaker decides whether that call is allowed to happen. That separation means the same wrapper can protect a free model route, a free server slot, or any OpenAI-compatible endpoint without changing the rest of the request path.

What this does and does not fix

An error-budget breaker prevents retry storms and makes rate-limit pressure visible. It does not change the provider limit, repair a misconfigured key, or stop an over-committed quota from being exhausted by other callers. It also does not replace logs, budget ledgers, or golden-set evaluation; those are separate seams for separate failure modes.

A 429 can arrive before the window has enough samples. The code opens immediately only when a Retry-After header is present. If the provider sends 429 without Retry-After, the call only contributes to the rolling failure rate. For routes that omit that header, I usually add a static cooldown or count a bare 429 as an immediate open signal; the decision depends on the provider contract, not on the wrapper.

This approach is not useful for one-off scripts with a single request, offline batch jobs where retry delay is cheap, or callers that cannot tolerate a half-open probe. It is most useful when the route is shared, the call volume is high, and the cost of a failed call is larger than the cost of waiting.

Run this harness against your own rate limit and print the state transitions before promoting a free route into production. The cheapest way to learn a provider's failure contract is with a breaker in front of it, not after a retry storm has already consumed the quota.

Top comments (0)