On this page 10 sections
- A small client, not a framework
- Typed requests, typed errors
- Retries that do not make outages worse
- Cancellation and the task that owns the call
- Decoding, dates and the awkward JSON
- Auth headers, tokens and 401s
- Logging without leaking personal data
- Testing with a URLProtocol stub
- Key takeaways
- Frequently asked questions
This sits alongside Swift concurrency in practice, which covers async/await and task isolation but not HTTP client habits. What follows is the client I keep reaching for: small, explicit, and boring to debug.
A small client, not a framework
The client should do a short list of jobs and no others:
- build a
URLRequestfrom a typed endpoint, - run it with
URLSession, - map transport failures and HTTP status codes into errors you can switch on,
- decode a successful body into a type,
- retry when that is safe,
- cancel when the caller goes away,
- attach auth headers without sprinkling tokens through the UI.
Anything else — image pipelines, websocket graphs, a magic “sync engine” — belongs in a different type. If you outgrow this, you will know, because you will have a second transport, not because a blog post said you needed a framework on day one.
A reasonable shape is a APIClient that holds a URLSession, a URL base, a JSON decoder, and a way to supply credentials:
import Foundation
struct APIClient: Sendable {
var session: URLSession
var baseURL: URL
var decoder: JSONDecoder
var credentials: @Sendable () async throws -> String?
func send<R: Decodable>(_ endpoint: Endpoint<R>) async throws -> R {
let request = try await request(for: endpoint)
return try await perform(request, as: R.self, retry: endpoint.isIdempotent)
}
}Use the shared session for ordinary, short calls, or a session you own if you need a custom URLSessionConfiguration (timeouts, cellular policy, a test protocol). Do not create a new URLSession per request; you will lose connection reuse, and you will forget to invalidate it.
Keep the base URL in one place, including the scheme and host. Concatenating path strings ad hoc is how you end up with a double slash or a missing /v1.
Typed requests, typed errors
An endpoint is a value: method, path, query, body, and whether a retry is safe. The response type is a generic parameter, so a call site cannot decode the same payload as two different types by accident.
struct Endpoint<Response: Decodable>: Sendable {
var method: String
var path: String
var query: [URLQueryItem] = []
var body: Data? = nil
var isIdempotent: Bool
}
enum APIError: Error, Equatable {
case badURL
case http(status: Int, body: Data)
case decoding
case transport(URLError.Code)
case cancelled
case unauthorized
case rejected(message: String)
}Mark GET, HEAD and PUT (when PUT is truly idempotent for your API) as safe to retry. Mark POST as not idempotent unless the server honours an Idempotency-Key you also control. That one flag will prevent a lot of double charges and double creates.
Map status codes on purpose:
- 2xx with a body: decode.
- 204: return
Voidif the endpoint says so; do not run a JSON decoder on an empty body. - 401: a distinct
unauthorized, so the caller can refresh a token or sign the user out. Do not retry a 401 with the same token. - 429 and 503: retryable, if the request is idempotent, preferably honouring
Retry-After. - Other 4xx: not retryable. Surface the body if it contains a message you can show.
- 5xx: retryable only when idempotent, and only a few times.
Transport errors from URLError need the same care. cancelled should become APIError.cancelled, not a generic failure banner. notConnectedToInternet and timedOut are the errors an offline UI should see. serverCertificateUntrusted is not something to retry in a loop.
If the API returns an error JSON, decode that type on the failure path and wrap it in rejected(message:). Guessing from a raw string is how you show the user a stack trace.
Retries that do not make outages worse
A retry can turn a brief outage into a stampede, or a “create order” button into two orders. The policy should be conservative and written down.
Rules I actually use:
- Retry only when
isIdempotentis true, or when you sent an idempotency key and the failure happened before you could know whether the server applied it. - Retry transport failures, 408, 429, and 5xx. Do not retry 400, 403, 404, 409 or 422. Do not retry 401 with the same credentials.
- Cap the attempts. Two retries (three attempts in total) is enough for a client. If it is still failing, show the error.
- Back off, and add jitter. A fixed 1-second retry synchronises every app in a failed cell. A simple exponential delay with a random offset is enough; you do not need a research-grade algorithm.
- Honour
Retry-Afterwhen it is a delay or a date you can parse. If the value is missing or nonsense, fall back to your own backoff. - Stop immediately on cancellation. A retry after the view has gone is a bug.
func perform<R: Decodable>(_ request: URLRequest, as type: R.Type, retry: Bool) async throws -> R {
var attempt = 0
let maxAttempts = retry ? 3 : 1
var lastError: Error = APIError.transport(.unknown)
while attempt < maxAttempts {
attempt += 1
do {
try Task.checkCancellation()
let (data, response) = try await session.data(for: request)
return try decode(data: data, response: response, as: type)
} catch is CancellationError {
throw APIError.cancelled
} catch let error as APIError where !isRetryable(error) || attempt == maxAttempts {
throw error
} catch let error as URLError where error.code == .cancelled {
throw APIError.cancelled
} catch {
lastError = error
let delay = backoff(attempt: attempt, retryAfter: retryAfter(from: lastError))
try await Task.sleep(for: .seconds(delay))
}
}
throw lastError
}
func backoff(attempt: Int, retryAfter: TimeInterval?) -> TimeInterval {
if let retryAfter, retryAfter > 0, retryAfter < 30 {
return retryAfter
}
let base = pow(2.0, Double(attempt - 1))
let jitter = Double.random(in: 0...0.4)
return min(8, base + jitter)
}The Retry-After cap in that snippet is deliberate. A 30-minute wait inside a screen’s .task is not a retry; it is a stuck spinner. If the server asks for a long wait, surface the error and let the user try again.
Retries and background delivery are different jobs. A BGAppRefreshTask may start a fetch; it should still use this client, and it should not multiply the client’s retries into a long background overrun. There is more on that in the BGTaskScheduler guide.
Cancellation and the task that owns the call
The async URLSession methods participate in Swift concurrency cancellation. If you call them from a SwiftUI .task, or from a Task you cancel in onDisappear or in a model’s invalidate(), the in-flight request is cancelled. That is what you want when the user leaves a screen.
Two ways this goes wrong:
- You start a
Taskand never keep it. A fire-and-forgetTask { await load() }in a button will outlive the screen, retry in the background, and write to a model that has been replaced. Hold the task, or use.taskand structured concurrency, as the concurrency guide recommends. - You swallow cancellation. If you catch
Errorand show “Something went wrong”, a cancelled request looks like a failure and may even retry. MapURLError.cancelledandCancellationErrortoAPIError.cancelled, and ignore that error in the UI.
URLSession.shared is fine here. If you use your own session, call finishTasksAndInvalidate() only when the client itself is being torn down, not after each request.
For file uploads and downloads, prefer upload(for:from:) and download(for:) (or the delegate-based transfer APIs) so the work can be a background URLSession if it must continue after the app is suspended. Ordinary JSON fetches should not use a background session; they will not finish more reliably, and they are harder to cancel and to test.
Decoding, dates and the awkward JSON
Most production bugs in a client are decoding bugs. The payload you were promised is not the payload you got.
Set the decoder up once, explicitly:
func makeDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
if let date = ISO8601DateFormatter.full.date(from: raw) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unrecognised date: \(raw)")
}
return decoder
}Pin the date format to what the server actually sends. “ISO 8601” in conversation often means several slightly different strings (fractional seconds, or not; Z versus an offset). One custom strategy you have tested against real fixtures is better than .iso8601 plus a stack of production crashes.
Awkward JSON is normal: a field that is a string or a number, a missing key that should have been null, an envelope { "data": …, "error": … }. Handle those with a small Decodable wrapper, not with JSONSerialization sprinkled in the view model. When I am staring at a fixture, I tidy it in the JSON Formatter so I can see the keys, then I add a test for that fixture. Do not decode only the happy example from the documentation.
If a field can arrive in two shapes, write a init(from:) that tries both and add a test for each. If a field is unused, you do not have to model it. If a field is required for the screen to make sense, do not supply a default that hides a server bug — fail decoding, log it, and show a recoverable error.
Auth headers, tokens and 401s
Attach tokens in the client, not in each view. A credentials closure, or a small AuthProviding type, keeps the rest of the app from reaching into the keychain.
func request<R>(for endpoint: Endpoint<R>) async throws -> URLRequest {
guard var components = URLComponents(url: baseURL.appending(path: endpoint.path), resolvingAgainstBaseURL: false) else {
throw APIError.badURL
}
if !endpoint.query.isEmpty {
components.queryItems = endpoint.query
}
guard let url = components.url else { throw APIError.badURL }
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
request.httpBody = endpoint.body
request.setValue("application/json", forHTTPHeaderField: "Accept")
if endpoint.body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
if let token = try await credentials() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}On 401, try a single token refresh, then retry the original request once, and only then sign the user out. The important word is single: if three screens fire at once, they must share one refresh in flight, otherwise you will rotate a refresh token twice and lock the user out. A Task stored on the client, reused by concurrent callers, is a usual way to do that.
If the access token is a JWT, you can read the expiry from the payload when debugging — the JWT decoder is built for that — but do not invent your own crypto, and do not log the raw token. Trust the 401 from the server as the source of truth; a client-side expiry check is an optimisation, not a guarantee, because of clock skew.
Never put a token in the query string. It will appear in logs, in referrers, and in the HTTP header inspector output when someone pastes a URL they should not have pasted.
Logging without leaking personal data
You will need logs. You should not need a GDPR incident.
Log the method, the path (not the full URL if the query contains names or tokens), the status code, the attempt number, and the duration. Do not log Authorization, cookie headers, request bodies that might contain passwords or personal data, or full response bodies in production.
In debug builds it is reasonable to log a truncated body when decoding fails. Even then, strip known sensitive keys. A simple allow-list of headers to print (Content-Type, Retry-After, Cache-Control) is safer than a block-list you will forget to update.
os.Logger with a subsystem and a category (network) is enough. Privacy levels on the logged values exist for a reason; use the private tier for anything that might identify a person, and keep the status code public so a device log still tells you “401 on /account”.
If you ever capture a failing request to share with a backend developer, paste it into a header inspector and remove credentials first. A redacted curl is a courtesy; an unredacted one is a leak.
Testing with a URLProtocol stub
Do not hit the live API in unit tests. Register a URLProtocol subclass on a URLSessionConfiguration, and have it return fixtures you own.
final class StubProtocol: URLProtocol, @unchecked Sendable {
static var request: URLRequest?
static var response: (Data, HTTPURLResponse)?
static var error: Error?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.request = request
if let error = Self.error {
client?.urlProtocol(self, didFailWithError: error)
return
}
if let (data, response) = Self.response {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
return
}
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
}
override func stopLoading() {}
}
func makeTestClient() -> APIClient {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [StubProtocol.self]
let session = URLSession(configuration: config)
return APIClient(
session: session,
baseURL: URL(string: "https://api.example.com")!,
decoder: makeDecoder(),
credentials: { "test-token" }
)
}With that in place you can write Swift Testing cases for: a 200 with a fixture, a 204, a 401, a 429 with Retry-After, a timeout, a cancelled task, a date the decoder must accept, and a body the decoder must reject. Assert that the Authorization header was set, and that a non-idempotent POST was not retried after a 500.
Reset the stub’s static state at the start of each test, or better, store the stub state in a context object if you start to see cross-test flakes. Static state is convenient and also a foot-gun in parallel tests; if you enable parallelisation, give each test its own configuration object rather than a global.
You still want one or two end-to-end checks against a staging server, run less often, to catch a contract change. Those are not a substitute for the stubbed tests, which should be the ones that run on every commit.
Key takeaways
- Write a small
URLSessionclient with typed endpoints and typed errors. A heavy framework is optional, not a starting requirement. - Retry only idempotent requests, only for transport, 408, 429 and 5xx, with a tight cap, backoff, jitter, and respect for
Retry-Afterand cancellation. - Let the task that started the call own it. Map cancellation to a distinct error and do not show it as a failure.
- Configure JSON decoding explicitly, and test the awkward fixtures, especially dates and envelopes.
- Attach tokens in the client, refresh once on 401, and never log credentials or personal bodies.
- Test with a
URLProtocolstub; keep live-network checks rare and separate.
If you want a second pair of eyes on a client or a retry policy, tell me about it.
Frequently asked questions
How do you retry a failed URLSession request in Swift?
Catch the failure, decide whether it is retryable (idempotent method, and a transport, 408, 429 or 5xx error), wait with backoff and jitter, and try again up to a small cap. Honour Retry-After when it is a short delay. Stop if the task is cancelled. Do not retry a POST that might have already created something, unless you have an idempotency key the server honours.
Should you use Alamofire or URLSession?
URLSession is enough for most apps, especially if you wrap it once. Alamofire can still help if you already use it, or if you need a feature you do not want to write yourself, but it is not a requirement for retries, typed errors or tests. Do not add a library only because a template included it.
How do you cancel a URLSession task when a view disappears?
Call the async URLSession APIs from a SwiftUI .task or from a Task you cancel when the view goes away. Those APIs honour cooperative cancellation and will fail with a cancelled URLError. Do not start a detached task you never store, and do not present cancellation as a user-facing error.
How should iOS apps handle offline API errors?
Map URLError.notConnectedToInternet, timeouts and similar transport codes to a typed error, and show a state the user can retry. Do not spin through your retry budget as if the next attempt will find a network that is not there; one or two tries with backoff is enough, then wait for a genuine retry action or a path change. Cache or queue only if you have a clear write model; guesswork offline queues are a good way to double-submit later.
How do you decode JSON errors from an API in Swift?
On a non-success status, try to decode a small error type from the body and wrap the message in your own error enum. If that fails, keep the status code and the raw body for logs (redacted), and show a generic message. Do not run your success Decodable type on a 4xx body and hope.
Written by Lee Johnson. Published 10 November 2026.