Your First PR on a Mobile AI Feature Should Be the Failure Path
It is your first Monday in a mobile repo that recently grew an AI feature, and the ticket assigned to you says something like "add a summarize button." The endpoint lives in an environment variable that points at a shared dev backend, and nobody in standup can tell you what the app does when that backend is asleep. This is the most common onboarding gap I see right now: teams ship AI surfaces faster than they document what happens when the network, the server, or the OS says no.
So your first pull request should not be the happy path. It should be the failure path, written as a test matrix you can run on one device in under an hour. Everything below is a proposed drill, not a benchmark report; the timings you collect belong to your device and your build, and you should treat them as evidence only after you write down the environment that produced them.
Why the failure path is the safest first PR
A happy-path PR teaches you where the button is. A failure-path PR teaches you where the app is fragile, and it does so without asking you to understand the whole model integration on day one. You only need three things: the base URL, the request shape, and the state machine that decides what the user sees.
- You cannot break production, because the drill runs against a stub you control.
- You produce a reusable artifact (the matrix) that every future teammate runs during onboarding.
- You force the team to name the retry policy, the timeout, and the offline behavior in review, not after an incident.
Keep the scope narrow. One screen, one request, one place in the code that owns timeout and retry, and one table in the PR description.
Build a stub you can break on purpose
You need an endpoint that misbehaves on command. The following file uses only the Python standard library, so a junior engineer can start it without touching the app's dependency tree.
# stub_model.py — stdlib only, no dependencies
import json, os, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MODE = os.environ.get("STUB_MODE", "ok")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
if MODE == "slow":
time.sleep(30) # longer than the client timeout on purpose
if MODE == "429":
self.send_response(429)
self.send_header("Retry-After", "5")
self.end_headers()
return
if MODE == "500":
self.send_response(500)
self.end_headers()
return
if MODE == "drop":
self.close_connection = True # no response bytes at all
return
payload = json.dumps({"text": "stub transcript", "echo_bytes": len(body)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass # keep the console readable during the drill
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8787), Handler).serve_forever()
Start it, then exercise each mode from your laptop before you touch the phone:
STUB_MODE=ok python3 stub_model.py &
curl -s -X POST localhost:8787/v1/complete -H 'Content-Type: application/json' -d '{"text":"hello"}'
# {"text": "stub transcript", "echo_bytes": 17}
kill %1
STUB_MODE=429 python3 stub_model.py &
curl -i -s -X POST localhost:8787/v1/complete -d '{}' | head -n 3
Point the device at the stub without editing source
Mobile networking details eat more onboarding time than the HTTP code itself. On the Android emulator, localhost is the emulator, so use 10.0.2.2 or forward the port with adb reverse; on the iOS simulator, localhost does reach your Mac. A physical device needs your LAN address, and Android 9 and later block cleartext HTTP unless you add a debug-only network security config.
adb reverse tcp:8787 tcp:8787 # emulator/device now reaches the stub at localhost:8787
Keep every base URL in environment configuration. If a teammate has to edit a Kotlin constant or a TypeScript literal to run the drill, the artifact will rot within a sprint.
One client function that owns timeout and classification
// modelClient.ts — the only place that decides what the user sees
type SendState = "idle" | "sending" | "slow" | "retryable" | "failed" | "done";
const TIMEOUT_MS = 12_000;
export async function sendTranscript(text: string, onState: (s: SendState, detail?: string) => void) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
onState("sending");
try {
const res = await fetch(`${process.env.MODEL_BASE_URL}/v1/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
signal: controller.signal,
});
if (res.status === 429 || res.status >= 500) {
onState("retryable", `status=${res.status} retry-after=${res.headers.get("retry-after") ?? "-"}`);
return;
}
if (!res.ok) { onState("failed", `status=${res.status}`); return; }
onState("done");
} catch {
onState("retryable", controller.signal.aborted ? "client-timeout" : "network");
} finally {
clearTimeout(timer);
}
}
Note what is deliberately absent: unbounded retries. A 429 with Retry-After should produce one scheduled retry, and a client timeout should be distinguishable from a transport error in your logs. That distinction is the whole point of the drill.
The matrix you actually put in the PR
Run each row twice, once on iOS and once on Android, and fill the last column with what you observed rather than what you hoped.
| # | Condition | How to induce it | Expected user-visible state | Pass criteria |
|---|---|---|---|---|
| 1 | Endpoint cold or asleep | Start with the stub stopped, tap Send | Spinner, then inline retry banner | No crash, no infinite spinner |
| 2 | Slow response | STUB_MODE=slow |
Spinner, then a "still working" state, cancel available | Cancel closes the socket |
| 3 | Rate limited | STUB_MODE=429 |
"Busy, retry in 5s" from Retry-After
|
No tight retry loop |
| 4 | Server error | STUB_MODE=500 |
Same retry affordance, error id surfaced | At most one automatic retry |
| 5 | Connection dropped mid-response | STUB_MODE=drop |
Classified as retryable, not as a parse error | No half-parsed payload shown |
| 6 | Airplane mode | Toggle from Control Center or Quick Settings | Explicit offline state, request queued with a label | Queue survives force quit |
| 7 | Backgrounded mid-request | Home button, then return | Result delivered once or discarded, never duplicated | No double send after resume |
| 8 | Process death |
adb shell am kill <pkg> during the request |
Draft text still present after relaunch | Storage key survives restart |
Record the device model, OS build, framework and dependency versions, network type, and battery state in the PR description. Without those, the table is an anecdote.
Where free model access and a free server fit
The drill needs two things a new hire usually cannot get on day one: a real model endpoint for the happy path, and somewhere to run a shared stub. The MonkeyCode open-source project currently offers free model access, described by the operator as 10M tokens, plus a free server option, which is enough to wire a device to something real without opening a budget request.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Three uses are honest and small. First, point MODEL_BASE_URL at a free model endpoint so the success path is real rather than mocked. Second, host the stateless stub on the free server option so your onboarding cohort shares one URL instead of five laptops. Third, re-run the matrix whenever the endpoint changes and diff the two log sets.
Limitations matter here. Free tiers are shared capacity, so cold starts and latency spikes are exactly the conditions you are testing, but you cannot attribute their cause from the client alone. Never send real transcripts, audio, or identifiers to a shared endpoint; use synthetic strings. Availability, the model list, and the token allowance are a snapshot that can change, so verify the current terms on the project page before you paste them into onboarding docs. There is no SLA, and nothing production-facing should route through a free endpoint.
You should skip this approach entirely if your data is regulated, if you need fixed latency SLOs, or if data residency is a requirement. Run the stub locally and keep the model endpoint internal in those cases.
What the reviewer should see
A good first PR from this drill is boring to review and expensive to lose. It contains the stub file, the filled matrix with your environment block, one screenshot per failure state, and a short paragraph on what still fails and why you left it out of scope. A reviewer should be able to reproduce any row in about ten minutes.
If you are in your first week, run the matrix once against the stub and once against a free model endpoint, then post both log sets side by side; that diff is the most useful onboarding document your team will read this quarter. When you report back, include your device, OS build, the transition that broke, and whether the app recovered, restarted, or silently dropped the request.
Top comments (0)