The incident report was three lines long: a refactor turn had been running against a remote seat, the office Wi-Fi dropped for eleven seconds, and the turn returned nothing at all. The local model was still loaded, the repository was still on disk, and the user was still waiting on a spinner. Nothing in the codebase had ever defined what should happen next, so the answer was a stack trace and a lost turn.
That gap is not a networking bug, and retrying harder does not close it. It is a missing contract between two seats that can both serve the same request. The pattern below is a composite of common local-first agent setups rather than a single team's incident, but the failure shape repeats often enough to be worth writing down.
Why a fallback contract beats a retry loop
A retry loop answers one question: how many times do we try again. A fallback contract answers four: which failures are worth retrying, which should hand the turn to the local seat, how long the user waits in each case, and how the interface tells the user which seat actually answered. The last question is the one most local-first projects skip, and it is the one that erodes trust fastest.
When provenance is invisible, a degraded local answer looks identical to a healthy remote answer. Users then attribute the smaller model's weaker output to the product rather than to the network. A contract that labels provenance turns an invisible degradation into an explainable event, which is a far cheaper fix than making the local path match the remote one.
Step 1: Classify the failure before you choose a residency
The useful unit of design is not "online versus offline" but a small set of failure classes with different correct responses. The budgets in the table are illustrative starting points for a discussion, not measured values; each team should set them from its own latency data.
| Failure class | Observable signal | Wrong response | Contract response |
|---|---|---|---|
| Hard partition | Connect error in under a second | Retry three times, then fail the turn | Local seat takes the turn immediately, output labeled local
|
| Soft timeout | No first byte before the deadline | Wait indefinitely | Local takes over with the remaining budget; remote work continues |
| Error-free stall | Socket open, no tokens emitted | Spinner with no telemetry | Watchdog emits a degraded event and the UI shows provenance |
| Remote 5xx burst | Three failures inside the window | Stack trace to the user | Circuit opens for the window, local serves the queue |
The key insight is that a timeout is not a cancellation. A request abandoned after the deadline may still occupy the remote seat and still consume whatever budget that seat has. That is why the contract needs an idempotency key stamped on the turn before the first attempt, so a later local answer can suppress a duplicate remote result instead of merging two divergent outputs.
Step 2: Encode the budget as data
Budgets that live in prose drift away from the code within a sprint. Put them in a frozen dataclass so tests can construct a tight budget without touching production defaults, and so the values appear in one reviewable place.
# fallback_contract.py
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Budget:
soft_deadline_s: float = 1.5 # local seat must answer within this
hard_deadline_s: float = 6.0 # remote seat gets no longer than this
circuit_window_s: float = 60.0
failure_threshold: int = 3
@dataclass
class Turn:
prompt: str
idempotency_key: str = field(default_factory=lambda: uuid.uuid4().hex)
provenance: str = "unset"
degraded: bool = False
def run_turn(turn: Turn, *, local, remote, budget: Budget, clock=time.monotonic) -> str:
start = clock()
try:
text = remote.send(
turn.prompt,
deadline_s=budget.hard_deadline_s,
idempotency_key=turn.idempotency_key,
)
turn.provenance = "remote"
return text
except (ConnectionError, TimeoutError):
turn.degraded = True
elapsed = clock() - start
remaining = max(0.0, budget.soft_deadline_s - elapsed)
text = local.send(turn.prompt, deadline_s=remaining, idempotency_key=None)
turn.provenance = "local"
return text
The remaining-budget arithmetic matters more than it looks. If the remote attempt already burned two seconds, handing the local seat a fresh 1.5 seconds silently violates the user's patience limit. Clamping to the remainder keeps the total turn inside the advertised envelope.
Step 3: Inject the failures instead of waiting for them
Waiting for a real partition is not a test strategy. A handful of fake transports reproduces every failure class in milliseconds, and they run in CI without network access at all.
# fake_seats.py
import time
class HealthySeat:
def send(self, prompt, *, deadline_s, idempotency_key):
return "remote answer"
class PartitionedSeat:
def send(self, prompt, *, deadline_s, idempotency_key):
raise ConnectionRefusedError("no route to seat")
class SlowSeat:
def send(self, prompt, *, deadline_s, idempotency_key):
time.sleep(deadline_s + 0.5)
raise TimeoutError("deadline exceeded")
class LocalSeat:
def __init__(self) -> None:
self.calls = 0
def send(self, prompt, *, deadline_s, idempotency_key):
self.calls += 1
return "local answer"
Each fake returns the same shape as the real transport, so the production call site stays unchanged. That constraint is worth enforcing: the moment a fake needs a special branch in run_turn, the harness has stopped testing the code that ships.
Step 4: Assert on provenance, not just output
The most valuable assertions are the ones about what the user is told. A turn that quietly falls back and reports nothing is a failing test, even when the text it returns is perfectly reasonable.
# test_fallback_contract.py
import pytest
from fallback_contract import Budget, Turn, run_turn
from fake_seats import HealthySeat, LocalSeat, PartitionedSeat, SlowSeat
@pytest.mark.parametrize("seat", [PartitionedSeat(), SlowSeat()])
def test_local_takes_over_and_says_so(seat):
local = LocalSeat()
turn = Turn(prompt="refactor auth.py")
out = run_turn(turn, local=local, remote=seat, budget=Budget())
assert out == "local answer"
assert turn.provenance == "local"
assert turn.degraded is True
assert local.calls == 1
def test_healthy_seat_keeps_provenance_remote():
turn = Turn(prompt="summarise diff")
out = run_turn(turn, local=LocalSeat(), remote=HealthySeat(), budget=Budget())
assert out == "remote answer"
assert turn.provenance == "remote"
assert turn.degraded is False
def test_idempotency_key_is_stable_across_fallback():
turn = Turn(prompt="migrate schema")
key = turn.idempotency_key
run_turn(turn, local=LocalSeat(), remote=PartitionedSeat(), budget=Budget())
assert turn.idempotency_key == key
Run the suite with duration reporting so the injected latency is visible rather than hidden:
python -m pytest test_fallback_contract.py -q --durations=5
The slow-seat case dominates the runtime, which is itself the diagnostic. If that entry vanishes from the duration report, the fake is no longer sleeping and the timeout path has stopped being exercised.
Where a free server genuinely wins, and where it does not
Local-first is not a moral position, and three tradeoffs decide it. Latency comes first: a small local model has predictable time-to-first-token but may lose badly on long generations, while a remote seat has variable network overhead and much stronger throughput once it starts. Secrets come second: credentials and private source should stay on the machine, with only a reduced payload crossing the network, so the remote path inherits whatever boundary the local redaction step enforces. Offline capability comes third, and it is the only one a local seat wins unconditionally.
This is the narrow place where MonkeyCode's free model access and free server option are relevant to the workflow rather than bolted onto it. A free remote seat makes the contract test cheap to run against real transport behavior instead of only against fakes, which is exactly the observation the harness above cannot provide. Availability, model selection, and any usage limits should be confirmed in the product documentation at the time of reading, since they change. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Limitations and who should skip this
The harness proves fallback behavior, not performance. Every number in the budget table is a placeholder chosen to make the tests readable, and no latency or cost comparison here should be quoted as a measurement. The synchronous example also cannot cancel in-flight remote work, so duplicate suppression via the idempotency key is mandatory rather than optional.
Teams with no local inference path should not adopt this pattern; they have one seat and the correct design is a queue with visible retry state. Teams under a strict data-residency rule should invert the contract entirely and treat the remote seat as unavailable by design, which makes the local path the only path and the provenance label redundant. Everyone else can start with two fake transports, one parametrized test, and a provenance field, then grow the failure table as real incidents arrive.
If you are mapping your own failure classes, running the same harness against a real free seat for an afternoon is a cheap way to replace guessed budgets with observed ones.
Top comments (0)