How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do with it?
Predict first
What does exponential backoff with a cap of 8 produce for six attempts?
import Foundation
func backoff(_ n: Int, base: Double = 0.5, cap: Double = 8) -> Double { min(cap, base * pow(2, Double(n))) }
print((0..<6).map { backoff($0) })
- [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
- [0.5, 1.0, 2.0, 4.0, 8.0, 8.0]
- [0.5, 1.0, 2.0, 4.0, 8.0, 16.0]
Predict first
A stub answers 503 twice and then 200. How many attempts does a retry loop make, and what status does it return?
var attempts = 0
Stub.handler = { _ in attempts += 1; return attempts < 3 ? (503, [:], Data()) : (200, [:], Data("ok".utf8)) }
func fetchWithRetry(max: Int) async throws -> Int {
var n = 0
while true {
n += 1
let (_, r) = try await session().data(from: URL(string: "https://example.test/r")!)
let code = (r as! HTTPURLResponse).statusCode
if code < 500 || n == max { return code }
}
}
print(try await fetchWithRetry(max: 5), attempts)
- 200 1
- 200 3
- 503 5
Mental model. Redialling a busy line: waiting a little longer each time and not all at the same second is polite. Redialling to place an order is only safe if the shop can tell it is the same order.
Three questions decide a retry. Is the failure transient? Retry timeouts, connection resets, DNS or connectivity errors that come back (URLError codes like .timedOut, .networkConnectionLost, .notConnectedToInternet after connectivity returns), and the HTTP statuses 408, 429, 500, 502, 503, 504 (honouring a Retry-After header when present). Do not retry client errors such as 400, 401 (refresh instead), 403, 404 or 422: the same request will fail again. Is it safe to repeat? A request is idempotent if doing it twice has the same effect as once: GET, PUT and DELETE are by definition, POST is not. Retrying a POST that created an order can create two. For non-idempotent requests send an idempotency key (a UUID header the server uses to recognise a repeat and return the first result), and only then retry. How long to wait? Use exponential backoff (base × 2ⁿ: 0.5, 1, 2, 4 seconds…), a cap (such as 8 or 30 seconds), a maximum attempt count and jitter (a random fraction of the delay) so thousands of clients that failed together do not all retry at the same instant and knock the recovering server over again.
Implementation details. Do the waiting with try await Task.sleep(for:) so it is cancellable: when the user leaves the screen the task is cancelled, the sleep throws, and the retry loop ends instead of continuing in the background; check Task.isCancelled before each attempt. Retry at one layer only (not in both an interceptor and the caller), or the attempts multiply. Surface the final error after the last attempt, with the attempt count in logs. Distinguish “retry now” (the connection dropped) from “retry when online” (use the path monitor or waitsForConnectivity). For user-initiated actions, give quick feedback and a manual retry rather than long silent backoff, and for background work use the system’s scheduling. Beware retry storms: a retry budget (for example no more than 10% extra traffic) and a circuit breaker that stops sending for a while after repeated failures protect both client and server. Test with a stub that fails a set number of times.
The delay doubles each attempt until it hits the cap, then stays there.
The loop retries on 5xx, so the third attempt succeeds and returns 200.
Common traps
- Retrying a POST without an idempotency key. A timeout after the server succeeded creates a duplicate.
- Retrying on a fixed delay with no jitter. Failed clients retry together and hit the recovering server in waves.
They might follow up with
- Which statuses are worth retrying? 408, 429, 500, 502, 503 and 504; honour
Retry-After. - Why is the wait made with
Task.sleep? It is cancellable, so leaving the screen stops the retries.