Retry with backoff, and the requests you must not retry
Retrying a failed request is easy. Retrying the right requests, at the right interval, is where the bugs are — and the most expensive mistake is retrying something that already succeeded.
The algorithm
Exponential backoff with jitter, in about twenty lines:
func fetch(_ request: URLRequest, attempts: Int = 3) async throws -> Data {
var lastError: Error?
for attempt in 0..<attempts {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { return data }
switch http.statusCode {
case 200..<300:
return data
case 429, 500..<600:
lastError = APIError.server(http.statusCode) // retry these
default:
throw APIError.client(http.statusCode) // do not retry
}
} catch let error as URLError where error.isRetryable {
lastError = error
}
if attempt < attempts - 1 {
let delay = pow(2.0, Double(attempt)) + Double.random(in: 0...0.5)
try await Task.sleep(for: .seconds(delay))
}
}
throw lastError ?? APIError.unknown
}
Delays of roughly 1s, 2s, 4s, with up to half a second of random jitter.
The jitter is not decoration. Without it, every client that failed during an outage retries at exactly the same moment, and the recovering server is hit by a synchronised wave that knocks it over again. This is a real failure mode with a name — the thundering herd — and half a second of randomness prevents it.
What to retry
This is the part that matters more than the backoff curve.
Retry: timeouts, connection lost, DNS failures, 429 Too Many Requests, and 5xx — server
errors that are plausibly transient.
Do not retry: 400, 401, 403, 404, 422. The request is wrong, and sending it again
produces the same answer while wasting the user’s battery. A 401 in particular needs a token
refresh, not a retry — retrying with the same expired token three times just delays the login
prompt.
extension URLError {
var isRetryable: Bool {
switch code {
case .timedOut, .networkConnectionLost, .notConnectedToInternet,
.dnsLookupFailed, .cannotConnectToHost, .cannotFindHost:
return true
default:
return false
}
}
}
Warning
URLError.cancelled must never be retried. It means the user navigated away or a newer request
superseded this one, and retrying it resurrects work that was deliberately abandoned. It is also
what Task cancellation produces, so retrying it defeats cancellation entirely.
Idempotency: the mistake that costs money
The dangerous case is a request that succeeded on the server but whose response never arrived. The client sees a timeout, retries, and the operation happens twice.
For a GET this is harmless. For “charge this card” or “send this message” it is not.
Only retry idempotent operations by default. GET, PUT and DELETE are idempotent by
definition. POST generally is not.
Where you must retry a POST, use an idempotency key:
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
The key must be generated once, outside the retry loop, so every attempt carries the same one. The server records it and returns the original response for a duplicate. If the backend does not support this, the honest answer is not to retry the request — surface the failure and let the user decide.
Respect Retry-After
A 429 or 503 often carries a header saying when to come back. Ignoring it and using your own
backoff is how you get rate-limited harder:
if let retryAfter = http.value(forHTTPHeaderField: "Retry-After"),
let seconds = Double(retryAfter) {
try await Task.sleep(for: .seconds(seconds))
} else {
try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
}
Free to implement and it is the server telling you the correct answer.
What URLSession already does
Worth knowing before writing any of this, because some of it is already handled:
waitsForConnectivityon the session configuration makes a request wait for a connection rather than failing immediately. For a user-initiated request that is often better than retrying.- Background sessions retry across app launches and system reboots. For uploads, that is a stronger guarantee than anything you can write in-process.
- HTTP/2 connection reuse means a “connection lost” is frequently recovered transparently before you see it.
The version I actually ship
Retry only idempotent requests, at most three times, with jitter, respecting Retry-After, never on
4xx except 429, never on cancellation, and log every retry with the reason.
That last part matters more than it sounds. A silent retry hides a degrading backend — the app looks fine while every request takes three attempts, and nobody finds out until the retries stop being enough.