API Integration
By the end of this lesson
Call services and degrade gracefully on poor connectivity.
The employees API does not change when a phone calls it. Everything around the call does. On a server, a request to another service travels over a stable link with predictable latency; on a device it travels over a radio whose quality depends on where the user is standing.
So the code that calls your API from a device needs things the server-side equivalent can often do without: a timeout on every request, a considered retry policy, the ability to cancel work the user no longer wants, and a real answer for what the screen shows when none of it succeeds.
Put all of that in one place. A single module that every screen goes through is how you guarantee no call ships without a timeout. Scatter fetch calls across twenty screens and at least one of them will hang forever, on a connection you cannot reproduce.
The specific network conditions worth designing against, rather than the generic idea of "bad signal":
- No connectivity at all — the fastest and most honest failure, and the easiest to handle well
- A connection that looks present and delivers nothing. The device reports it is connected, the request is accepted, and no response arrives. Without a timeout this is an infinite spinner
- A captive portal, common on client and hotel wifi, which intercepts your request and returns a sign-in page. Your code receives a 200 with HTML where it expected JSON
- Connectivity that comes and goes mid-request, so a call fails at the exact moment the radio switches from wifi to mobile data
- High latency with eventual success. The response arrives after nine seconds, by which time the user has tapped the button twice more
- The user navigating away, or the app being backgrounded, while a request is in flight. The response arrives for a screen nobody is looking at, or does not arrive at all because the app was suspended
- A response that arrives after the user has signed out, which must not be allowed to populate a screen with the previous user's data
export class HttpError extends Error {
constructor(readonly status: number) {
super("HTTP " + status);
}
}
export class OfflineError extends Error {}
/** One place owns timeouts, so no call site can forget one. */
export async function getJson<T>(
url: string,
callerSignal: AbortSignal,
timeoutMs = 10000,
): Promise<T> {
const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: AbortSignal.any([callerSignal, timeout.signal]),
headers: { Accept: "application/json" },
});
if (!response.ok) throw new HttpError(response.status);
const type = response.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
throw new OfflineError("Expected JSON. A network may be intercepting.");
}
return (await response.json()) as T;
} finally {
clearTimeout(timer);
}
}- The shape is what transfers. Your platform's HTTP client will spell cancellation differently — a cancellation token, a disposable subscription, a call object you cancel — and the structure stays the same: a caller-owned cancellation, a timeout you create, and both able to stop the request.
- Ten seconds is a starting point, not a rule. A list the user is waiting on should give up sooner, around five to eight seconds, because past that they have concluded it is broken. A background upload can afford much longer. The unacceptable value is no timeout at all, which is the default in more clients than you would expect.
- The caller's signal is how a screen cancels its own work. When the user navigates away, the screen aborts, the request stops, and the radio stops being used for an answer nobody wants. Without it you pay battery for a response you will discard.
- The content type check catches the captive portal case. A sign-in page returned with a 200 status looks like success to your code and then fails inside JSON parsing with a message about unexpected characters, which is a confusing thing to show a user and a worse thing to debug from a crash report.
- The finally clause clears the timer on every path, including cancellation. A leaked timer that fires later aborts a controller nobody is using, which is harmless here and exactly the kind of loose end that becomes harmful once this function grows.
const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
function isWorthRetrying(error: unknown): boolean {
if (error instanceof HttpError) return RETRYABLE_STATUSES.has(error.status);
// A transport failure: the request never got an answer.
return error instanceof TypeError || error instanceof OfflineError;
}
const delay = (ms: number) => new Promise((done) => setTimeout(done, ms));
export async function withRetry<T>(
call: () => Promise<T>,
attempts = 3,
): Promise<T> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await call();
} catch (error) {
const lastAttempt = attempt === attempts - 1;
if (lastAttempt || !isWorthRetrying(error)) throw error;
const base = 500 * 2 ** attempt; // 500ms, then 1s, then 2s
const jitter = Math.random() * base * 0.3;
await delay(base + jitter);
}
}
throw new Error("unreachable");
}- Again, read the structure rather than the API. Most platforms have a retry helper somewhere; what matters is that the decision about what to retry is written down once and is specific about it.
- The backoff doubles. Retrying immediately three times on a weak connection is three failures in under a second, which helps nobody and keeps the radio awake. Waiting 500ms, then a second, then two gives the connection a chance to actually come back.
- The jitter is not decoration. Without it, every device that lost connectivity at the same moment retries at the same moment, and a server recovering from an incident is hit by a synchronised wave. A random spread of up to thirty percent is enough to break that up.
- Three attempts and a cap on the total wait is deliberate. Retrying indefinitely on a device drains the battery and hides the problem from the user, who would rather be told there is no connection than watch a spinner for two minutes.
- The classification is the important line in this sample. A 404 or a 422 will fail identically every time, so retrying it wastes power and delays an honest message. A 503 or a dropped connection may well succeed a second later. Note also that 429 means the server is asking you to slow down, and if it sends a Retry-After header, honouring that value takes precedence over your own backoff.
Retrying a read is straightforward. Retrying a write needs care, because a request that timed out may have been applied by the server before the connection dropped:
- Safe to retry as-is
- GET requests, and writes that produce the same result however many times they run — a PUT that sets a record to a known state, or a DELETE of a specific id. Running these twice leaves the same outcome as running them once.
- Not safe without help
- A POST that creates something. If it timed out, you do not know whether the visit note was recorded. Retrying risks two notes; not retrying risks none. Neither option is acceptable as a coin flip.
- Idempotency key
- The way out. The client generates a unique identifier for the intended change and sends it with the request. If the server has already applied that key it returns the original result instead of creating a second record. The key belongs to the change, so it stays the same across every retry — generating a new one per attempt defeats the whole mechanism.
- Never worth retrying
- 400, 401, 403, 404 and 422. The server understood you and said no. Retrying changes nothing except battery life, and it delays telling the user something they could act on. A 401 needs a token refresh or a sign-in, which is a different path entirely.
- Cap the whole attempt, not each try
- Set a limit on total elapsed time as well as on the number of attempts. Three retries with generous timeouts can add up to a minute, which is far longer than any user will wait while watching a screen.
Summary
- Route every call through one module so timeouts and cancellation cannot be forgotten, and discard late responses whose owner is gone
- Design for the connection that looks present and delivers nothing, not only for no signal at all
- Retry transport failures and 5xx with doubling backoff and jitter; never retry a 400, 404 or 422
- Retrying a create needs a stable idempotency key, generated per change rather than per attempt
- Offline is a state with its own message, saved data and queued input — not a generic error
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Run the app on a deliberately bad connection
Configure your device or simulator to a slow, lossy network profile, then use an app you are building for five minutes. Note every screen that offers no way out of a pending state.
Then enable aeroplane mode mid-request and note what each screen says.
Show solution
The usual results are a spinner with no timeout somewhere in a secondary flow, and at least one screen showing a generic error for what was plainly a missing connection.
Aeroplane mode mid-request is worth doing separately because it exercises a different path from starting offline. Starting offline often takes an early branch where connectivity is checked before the call; losing the connection during a request goes through the failure handling, which tends to be less considered.
The reason this belongs in your routine rather than in a one-off audit: network handling degrades quietly. A screen added next month will use fetch directly, without the timeout, and nothing will fail on your desk.
Think about it
Decide the retry policy for four calls
The field app makes these calls to the employees API: fetch today's visit list; create a visit note; update an employee's site code; upload a photo attachment.
For each, decide whether to retry, how many times, and what the user sees while it happens. Say which ones need an idempotency key.
Show solution
Fetching the visit list is a read, so retry freely — two or three attempts with backoff, then fall back to the cached copy with its age shown. The user need not see the retries at all if a cached list is already on screen.
Creating a visit note needs an idempotency key, generated when the engineer taps Save and reused for every attempt including attempts made days later from a queue. Without the key, a timeout leaves you unable to retry safely, and the honest fallback is asking the user to check whether their note arrived, which is a poor experience for something the client could have solved.
Updating a site code with a PUT that sets a known value is idempotent, so retrying is safe. If the update is a partial change, add a version check so you do not overwrite a newer edit made elsewhere — the subject of the next lesson.
The photo upload is different in kind. It is large, slow and likely to be interrupted, so it should not be a foreground request the user waits on at all. Save it locally, queue it, upload in the background, and show its state on the note. Retry generously here, with long backoff, because there is no user watching.
The pattern across all four: retry policy follows from whether the call is a read, an idempotent write, a create, or a background transfer. That classification, made once per call, is more useful than a single global retry setting.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.