DEV Community

Riley Xu
Riley Xu

Posted on

Migration Diary: Classify Every Retry Before the Free Server Inherits Paid Backoff

You should classify every retry before a free server inherits the backoff your paid agent runtime still uses. A policy tuned for a fast metered endpoint can stampede a shared free process or replay a write you already committed. This diary walks you through a cutover plan, a decision table, and a small policy module you can test locally. Nothing here is a benchmark, a quota, or a claim that a free tier will stay available.

Name the leftover before you cancel the paid workspace

You are not only swapping a model name when you leave a paid agent workspace for a cheaper host. You are also leaving timeout defaults, retry headers, and a habit of treating errors as cheap because the invoice made them visible. The leftover sits in the client, the worker, and the CI job that still sleeps and tries again. If you close the paid workspace first, that schedule keeps firing at whatever URL you forgot to rebind.

A free model tier and a free server can host the canary only after you shrink what a retry may do. Operator notes for this draft say MonkeyCode is open source and offers free model access plus a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. You still read current product documentation for limits, because this note states no quotas, hardware, or duration.

The article stays useful if you drop that product and keep the same plan on any free model and free server. You should not copy numbers from a marketing page into a runbook without a primary document dated for your cutover week. Retry classification is an evergreen migration problem, not a claim about this week's model leaderboard or a vendor score. You verify every time-sensitive limit yourself before you publish a runbook or flip any traffic onto the new host.

Decide which failures may try again

You write the decision table before you touch code, and you keep it next to the workflow config. A timeout on the model call is not the same event as a tool that already charged a card or opened a ticket. Transport failures may retry inside a small budget, while committed side effects must stop and wait for a human. Use the table below as a starting contract, then replace the signals with the ones your logs actually emit.

Class Signal you can observe On the free server Next action
Transport Connection reset, HTTP 502, HTTP 503 One retry, delay set by you Stop if the budget is spent
Deadline Client timeout before any tool result Retry only if no tool started Record the attempt id
Client fault HTTP 400 or schema mismatch No retry Fix the request, do not loop
Tool committed Tool returned a receipt or external id No retry Reconcile, do not call again
Unknown Empty body or unparsed error No retry Send to the dead-letter path

You treat unknown as stop, because a free server will not tell you whether the paid side effect already landed. You also refuse to retry a client fault, because a tighter free-model parser will keep rejecting the same bad payload. The table is a proposal you adapt to your signals, not a universal standard published by any vendor. If your domain has a safer class split, you write that split down and test it.

Cutover plan

1. Inventory every inherited retry

You search the repository before you argue about policy, because forgotten sleeps cause more damage than a wrong constant. Run the search from the repository root and review every hit that is not a vendored dependency. You are looking for max attempts, backoff helpers, and framework defaults that retry on any exception. Write the file path and the current attempt count into the cutover note before you edit anything.

rg -n -i "retry|backoff|max_attempts|tenacity" --glob '!vendor/**' --glob '!node_modules/**'
git grep -n -E "sleep|wait_exponential|stop_after_attempt" -- "*.py" "*.yml" "*.yaml"
Enter fullscreen mode Exit fullscreen mode

You expect this search to find client code, worker code, and at least one workflow file. If the search returns nothing, you still check framework defaults, because silence does not mean the runtime will try only once. You paste the file list into the migration note so the next person can see what you froze. You do not delete those call sites until the new classifier is imported and the old helper is unused.

2. Separate transport retries from tool retries

You give the free server a policy that stops when it sees a tool receipt instead of trying the call again. The module below is an unexecuted proposal you copy into your repository and review like any other patch. It refuses to retry once a tool reports a receipt, and it caps transport retries at one. It also stamps each decision so your logs can show why a second call did or did not leave the worker.

# Unexecuted proposal for Python 3.10+. This draft did not run it.
from dataclasses import dataclass

TRANSPORT = {"connection_reset", "http_502", "http_503"}
DEADLINE = {"client_timeout"}

@dataclass(frozen=True)
class RetryDecision:
    allow: bool
    reason: str
    attempt_budget: int

def classify(signal: str, tool_receipt: str | None, attempt: int) -> RetryDecision:
    if tool_receipt:
        return RetryDecision(False, "tool_already_committed", 0)
    if signal in {"http_400", "schema_mismatch"}:
        return RetryDecision(False, "client_fault", 0)
    if signal not in TRANSPORT | DEADLINE:
        return RetryDecision(False, "unknown_stop", 0)
    if attempt >= 1:
        return RetryDecision(False, "budget_spent", 1)
    return RetryDecision(True, "transport_or_deadline_once", 1)

def delay_seconds(decision: RetryDecision, measured_delay: float) -> float:
    # Caller supplies measured_delay after its own timing run. Do not invent one here.
    if not decision.allow:
        return 0.0
    return measured_delay
Enter fullscreen mode Exit fullscreen mode

You keep the attempt budget at one until you have measured your own latency and error mix on the free server. This draft does not invent that measurement, and you should not paste a blog backoff curve into production. If your idempotency ledger is not already sealed, you finish that work before you enable even this single retry. A classifier cannot protect a tool that applies a payment twice under two different idempotency keys.

3. Shadow the classifier on recorded failures

You run the classifier against saved failure records before it is allowed to change live behavior. A small fixture file is enough, as long as you include one committed tool receipt and one bare timeout. You assert the decision, not a latency number, because this test does not call a model and does not spend a quota. The commands below are the check you run in CI on the branch that still points at the paid endpoint.

# Unexecuted proposal. Add these assertions beside the module, then run them yourself.
def test_timeout_may_retry_once():
    decision = classify("client_timeout", tool_receipt=None, attempt=0)
    assert decision.allow is True
    assert decision.reason == "transport_or_deadline_once"

def test_receipt_never_retries():
    decision = classify("http_503", tool_receipt="rcpt_1001", attempt=0)
    assert decision.allow is False
    assert decision.reason == "tool_already_committed"

def test_second_attempt_stops():
    decision = classify("http_502", tool_receipt=None, attempt=1)
    assert decision.allow is False
    assert decision.reason == "budget_spent"

def test_unknown_stops():
    decision = classify("", tool_receipt=None, attempt=0)
    assert decision.allow is False
    assert decision.reason == "unknown_stop"
Enter fullscreen mode Exit fullscreen mode
python -m pytest tests/test_retry_budget.py -q
Enter fullscreen mode Exit fullscreen mode

You treat a red test as a block on the cutover, not as a warning you can waive in chat. You also add one fixture from a real redacted log if your policy allows that copy, and you keep secrets out of the fixture. If you cannot redact the log, you synthesize the signal names and you do not paste production payloads into the branch. The goal is a repeatable yes or no, not a story about how the last outage felt.

4. Flip the canary only after the paid path remains a rollback

You change configuration in two steps, and you never cancel the paid workspace in the same change window. First you point one workspace at the free model and free server, with the new classifier imported on that path. Then you watch attempt counts, dead-letter volume, and tool receipt duplicates for a period your own error budget defines. This note does not name that period, because a safe window depends on your traffic and is not a universal constant.

# Proposed canary fragment. Replace hosts from your own current documentation.
agent:
  runtime: free_server
  model_source: free_tier
  retry_policy: classify_once
  rollback_runtime: paid_workspace
  cancel_paid_workspace: false
Enter fullscreen mode Exit fullscreen mode

You leave the cancel flag false until the canary has a written rollback drill and a named owner. You rehearse the drill by sending one synthetic timeout and confirming the second attempt does not fire when a receipt exists. You do not rehearse the drill with a live payment tool or any other irreversible external write. After the drill, you schedule the paid workspace closure as a separate change, with the classifier still in place.

Leftovers that still steer the new path

The retry helper is not the only leftover that survives after you cancel the paid workspace. Your alert still pages on a threshold that assumed a paid error rate, and that page will tire on-call during a cold start. Your dashboard may still divide failures by spend, which hides a retry storm that costs tokens you no longer itemize the same way. You update the alert to count classified retries and dead letters, and you keep the old chart until the canary window ends.

You also check the queue consumer, because a worker outside the agent can still apply the paid backoff you thought you deleted. A second process with its own sleep loop will ignore the Python classifier and hammer the free server anyway. You add the same decision function to that consumer or you disable its retry until the port is done. Two policies in one workflow are how a cutover looks green in agent logs and red in the ticket system.

Limits and who should skip this plan

You should not use this plan when you cannot tell a transport error from a committed tool result. You should also skip it for payment capture, credential rotation, or any write that a second call would duplicate without an idempotency key. A free server is a poor place to discover that your domain needs a human approval queue you have not built yet. If your service level needs a latency bound you have not measured on the free path, you keep the paid runtime.

This proposal does not prove that one retry is safe for your traffic, and it does not compare vendors. It fails open if you catch a bare exception and pass an empty signal into the classifier function. An empty signal counts as stop only when your wrapper actually calls the function with the name unknown. You should wrap the client so unknown exceptions become the signal unknown and then hit the stop branch.

You review that wrapper in the same pull request as the policy module, before any canary config merges. If the golden tests and the shadow run agree, point one canary workspace at the free model and the free server. Keep the paid endpoint as rollback until your next release review, and do not close that workspace in the same change. That pairing is the only invitation this cutover needs, and you still confirm limits in the current product docs first.

Top comments (0)