Persistence and networking · core
HTTP in practice: requests, methods and status codes
Every request in this curriculum so far has been a bare GET. Real apps send data, and what they send decides whether a retry is safe, whether a 403 turns into an infinite loop, and whether a user is charged twice.
By the end you will be able to
- Build a URLRequest with a method, headers and a body, and encode a query without breaking on user input
- Explain which HTTP methods are safe and which are idempotent, and why that decides whether a retry is allowed
- Route the status codes that need different app behaviour — 401 against 403, 409, 429 and Retry-After
Your app sends POST /payments to move £40. The request times out. The retry loop you built in D2-01 sends the same request again. What has the app just risked?
The request you have not built yet
Every network example so far in this curriculum has looked like this:
let (data, response) = try await URLSession.shared.data(from: url)
That call sends a GET — a request that asks for something and sends no body with it. It is the right shape for a feed, a profile or a search result, which is why it carries most tutorials.
An app does more than read. It signs users in, saves edits, uploads receipts and moves money. Each of those sends data, and to send data you build the request yourself:
struct Payment: Encodable {
let amountPence: Int
let payeeID: String
}
func send(_ payment: Payment, token: String, idempotencyKey: UUID) async throws -> (Data, URLResponse) {
var request = URLRequest(url: URL(string: "https://api.example.com/payments")!)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(payment)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(idempotencyKey.uuidString, forHTTPHeaderField: "Idempotency-Key")
return try await URLSession.shared.data(for: request)
}
Four pieces of vocabulary sit in there.
A URLRequest is the whole request as a value you can hold, inspect and change before it is sent. Note that it is a var: you build it up line by line.
The method is the verb — GET, POST, and the three others below. It tells the server what kind of operation this is.
The body is the data travelling with the request. Reads usually have none. Writes carry the thing being written, almost always as JSON.
A header is a name: value pair of metadata about the request, sitting alongside the body rather than inside it. Content-Type says what the body is; Accept says what the app would like back; Authorization carries the token that proves who is calling.
Also notice the call changed. URLSession.shared.data(from: url) takes a URL and can only ever send a GET. data(for: request) takes a URLRequest, so it can send anything. In production code you almost always want the second one.
The five methods, and the one distinction that changes your code
There are five methods you will use, and any REST API you meet will be built from them:
| Method | Means | Sends a body |
|---|---|---|
GET | Read something | No |
POST | Create something, or run an operation | Yes |
PUT | Replace something completely | Yes |
PATCH | Change part of something | Yes |
DELETE | Remove something | Usually not |
That table is worth knowing and is not where the engineering is. Two properties cut across it, and those properties are what your networking layer actually has to reason about.
Safe means the request does not change anything on the server. GET is safe. Everything else in the table is not.
Idempotent means applying the request twice leaves the server in the same state as applying it once. The word is worth saying slowly, because it does not mean "the second attempt does nothing". It means the end state is the same either way.
PUT /profile/name with the body "Ashish" is idempotent: the name is set to "Ashish", and setting it again sets it to "Ashish". DELETE /cards/9 is idempotent: after the first one the card is gone, and a second one finds nothing to remove and leaves the world unchanged. GET is idempotent because it changes nothing at all — every safe method is automatically idempotent.
POST /payments is not. Each one creates a new payment.
Why the method decides whether a retry is allowed
Now the rule the pretest was reaching for, stated in full:
Retry a failed request only when repeating it is harmless — because the method is idempotent, or because the request carries an idempotency key.
D2-04 built the second half already. An idempotency key is a unique string the client generates once and attaches to every attempt to deliver the same operation. The server remembers keys it has already applied, so a repeat arriving with a key it has seen is recognised and ignored rather than applied again. That is what makes a POST safe to repeat when the method alone never could be.
Put the two halves together and every request can answer the question for itself:
The two POST /payments lines are the whole lesson in two lines of output. Same endpoint, same method, opposite verdicts. The only difference is a header.
This is also the join between two lessons you have already done. D2-01 taught the retry loop. D2-04 taught idempotency keys. Neither said out loud that the first one is only safe because of the second one — and that sentence is what an interviewer is listening for when they ask you to design a networking layer.
A payments server applies whatever arrives. Two payments are sent; each first reply is lost, so the client retries each one. The second payment carries an idempotency key and the first does not.
final class PaymentServer {
var ledger: [String] = []
var seenKeys: Set<String> = []
func charge(pence: Int, key: String?) -> String {
if let key = key {
if !seenKeys.insert(key).inserted {
return "duplicate ignored"
}
}
ledger.append("\(pence)p")
return "applied"
}
}
let server = PaymentServer()
print(server.charge(pence: 4000, key: nil))
print(server.charge(pence: 4000, key: nil))
print(server.charge(pence: 2500, key: "k-77"))
print(server.charge(pence: 2500, key: "k-77"))
print("ledger:", server.ledger)Building the URL without breaking it
Reads need query parameters — the ?q=tea&sort=newest on the end of a URL. Building that by pasting strings together is the most common way an app breaks on real user input:
// Broken as soon as anyone types a space, an ampersand or a plus sign.
let url = URL(string: "https://api.example.com/search?q=\(searchText)&sort=newest")!
A space is not legal in a URL. An & inside a value ends the value and starts a new parameter. A + means a space to many servers. So a user searching for tea & coffee sends a parameter named tea and a broken one called coffee, and the app either fails or silently searches for something else. URL(string:) returns nil for some of these, which is how the force-unwrap above becomes a crash report.
The fix is URLComponents, which percent-encodes each value for you:
var components = URLComponents(string: "https://api.example.com/search")!
components.queryItems = [
URLQueryItem(name: "q", value: searchText), // "tea & coffee" → tea%20%26%20coffee
URLQueryItem(name: "sort", value: "newest"),
]
let url = components.url!
Percent-encoding is how a character that would otherwise mean something structural gets carried safely: it becomes a % and the two hex digits of its byte. A space becomes %20, an ampersand %26. The server decodes them back before it reads the value.
The simulation below builds both versions of the same search so you can see them side by side:
Read the second line of output as a server would. After q=swift 6 , the & begins a new parameter, so the search term is truncated and the server is handed a parameter named concurrency with no value. The + in c++ arrives as two spaces. Nothing throws, nothing is logged, and the user simply gets the wrong results — the same silent-wrongness failure D2-01 warned about with summary ?? "".
Headers worth sending on every request
Three headers belong on essentially every request an app makes, and a fourth is the one seniors add.
Authorization: Bearer <token> carries proof of who is calling. D2-08 is entirely about what that token is and how it stays fresh.
Content-Type describes the body you are sending — application/json for JSON. Send a JSON body without it and many servers answer 415 Unsupported Media Type, which reads like a mystery until you know this header exists.
Accept describes what you would like back. Setting application/json explicitly stops a server from deciding to send you HTML on an error path — a real cause of decode failures that look inexplicable.
The fourth one costs nothing and pays for itself the first time something goes wrong: a request id you generate yourself, a fresh UUID per request, sent in a header your backend agrees to log. When a user reports "the transfer screen hung at 14:32", a request id turns a search across several teams' logs into one lookup. D2-09 covers what else to do with it.
The status codes that change what your app does
D2-01 taught the families — 4xx is your request being refused, 5xx is the server failing — and that split carries most of the work. Below the families, a handful of individual codes each demand different behaviour, and these are the ones interviews probe:
| Code | Means | What the app does |
|---|---|---|
200 OK | Here is the thing | Decode it |
201 Created | Made it; the Location header says where | Store the id the server assigned |
202 Accepted | Taken, not finished yet | Do not show success — poll, or wait for a push |
204 No Content | Worked; there is no body | Do not try to decode. This is where "unexpected end of JSON" comes from |
304 Not Modified | Unchanged since your last copy | Keep what you have — D2-03's ETag path |
400 Bad Request | The request is malformed | Your bug. Log it loudly; a retry cannot fix it |
401 Unauthorized | I do not accept this token | Refresh the token, then retry once |
403 Forbidden | I know who you are; you still may not | Show the limit. Never refresh, never retry |
404 Not Found | Nothing lives here | Show "unavailable" |
409 Conflict | Someone else changed it first | Re-fetch, merge or ask the user — D2-04's conflict path |
412 Precondition Failed | Your If-Match version is stale | Re-fetch and try again with the current version |
422 Unprocessable Content | Well-formed, but the values are invalid | Show the field errors from the body |
429 Too Many Requests | Slow down | Wait as long as Retry-After says |
Routed in code, that becomes a single function every screen can rely on:
401 and 403 are not the same failure
Both mean "no". They mean it for opposite reasons, and treating them alike produces one of the nastiest loops an app can ship.
401 Unauthorized means the server did not accept your token. Perhaps it expired. A new token would fix it, so the right response is to refresh and retry once.
403 Forbidden means the server accepted your token, knows exactly who you are, and this identity is not allowed to do this. A free-plan user asking for a premium report. An account without permission for a shared workspace. A new token changes nothing, because the token was never the problem.
Here is what happens when the networking layer treats 403 like 401. Follow the sequence:
- A free-plan user opens the premium insights screen. The server answers 403.
- The layer sees "not allowed", assumes the token is stale, and refreshes it.
- The refresh succeeds — nothing was ever wrong with the token — so the layer retries the request with a brand-new, perfectly valid token.
- The server answers 403 again, for the same reason as before.
- Go to step 2.
The screen spins forever. Every loop costs two network requests, so the phone's battery drains and the auth server sees a flood of refreshes from one user, which is exactly the pattern its own rate limiter is built to punish. Eventually the auth server starts answering 429, and now the user cannot sign in on any screen — because of a report they were never entitled to see.
One case 403 prevents all of it.
429, and the number the server hands you
429 Too Many Requests means you are asking too often. Rate limiting is the server protecting itself by capping how many requests one client may make in a window.
D2-01 taught exponential backoff with jitter for the case where you have to guess how long to wait. A 429 usually removes the guessing: the response carries a Retry-After header saying how long until the limit resets. Honouring it beats any curve you invent, because the server knows when its own window opens and you do not.
Two details in that loop are deliberate. waitMillis falls back to D2-01's doubling delay when the header is absent, because Retry-After is optional and plenty of servers omit it. And the sleep is try await, never try? — the reason is the one D2-01 gave: try? swallows the CancellationError and leaves the loop running after the user has walked away.
Retry-After also appears on 503 Service Unavailable, where it means "we are down until then". The same handling covers both.
Your networking layer retries any 5xx up to three times with backoff. A cash-transfer screen calls POST /transfers, with no idempotency key. The server is mid-deploy and answers 503 twice before succeeding. What does the user see?
Design the request layer for a food-delivery app. Write down four things: the method and path for placing an order, cancelling an order, editing the delivery address, and reading order status. For each one, say whether it is idempotent, and whether your retry loop is allowed to touch it. Then answer the hard part. A user taps "Place order", the request times out, and they tap it again. Describe exactly what stops them being charged twice — name the mechanism, say which side of the network it lives on, and say when the key is generated.
You can now:
- Build a
URLRequestwith a method, headers and a body, and encode a query withURLComponentsrather than string interpolation - Say which methods are safe and which are idempotent, and treat the method as an advertisement rather than a guarantee
- Apply the full retry rule: repeat only what is idempotent or carries an idempotency key
- Route the individual status codes that need different behaviour, and keep 401 and 403 apart
- Honour
Retry-Afterinstead of guessing at a backoff
Next up: authentication — what the token in that Authorization header actually is, how a JWT is built, and how a sign-in flow works on a device that cannot keep a secret.