Why does retry sometimes not retry anything, and how do Future and Deferred differ here?
Predict first
How many times does each closure run with retry(2)?
import Combine
var bag = Set<AnyCancellable>()
struct E: Error {}
var futureRuns = 0
let future = Future<Int, E> { promise in futureRuns += 1; promise(.failure(E())) }
future.retry(2).sink(receiveCompletion: { _ in print("future completed") }, receiveValue: { _ in }).store(in: &bag)
print("Future closure ran", futureRuns, "time(s) with retry(2)")
var deferredRuns = 0
let lazy = Deferred { Future<Int, E> { promise in deferredRuns += 1; promise(.failure(E())) } }
lazy.retry(2).sink(receiveCompletion: { _ in print("deferred completed") }, receiveValue: { _ in }).store(in: &bag)
print("Deferred closure ran", deferredRuns, "time(s) with retry(2)")
- Future closure ran 1 time(s) / Deferred closure ran 3 time(s)
- Future closure ran 3 time(s) / Deferred closure ran 3 time(s)
- Future closure ran 1 time(s) / Deferred closure ran 1 time(s)
Mental model. Asking a friend to re-read the note they wrote (a Future) just repeats the same words. Asking them to ask the question again (Deferred) can get a different answer.
retry(n) works by re-subscribing to the same upstream publisher after a failure. If subscribing to it again does the work again, you get a real retry; if it just replays something that already happened, you do not. A Future runs its closure once, when it is created, and remembers the result: re-subscribing hands back the same stored failure. The check shows the Future closure ran one time even with retry(2), then the failure was delivered. Wrapping it in Deferred { Future { … } } creates a fresh Future per subscription, so the closure ran three times (the first attempt plus two retries). Rule: make the thing you want to repeat lazy, wrapped in Deferred (or return a new request publisher from inside flatMap, Q305).
Two more details. retry re-subscribes immediately, with no delay: for network calls add a delay or backoff (delay between attempts via catch { … delay(…).flatMap { retry } }, or a small custom operator), retry only transient failures (a timeout, a 503) and never non-idempotent writes without an idempotency key (Q324). And a retry that exhausts its attempts re-emits the last failure, so the downstream error handling still has to exist.
A Future runs once at creation; Deferred creates a new Future on each subscription.
Common traps
- Putting
retryafter aFutureand expecting a new request. It replays the stored result. - Retrying immediately and unconditionally. It hammers a struggling server and repeats non-retryable errors.
They might follow up with
- What does
retry(0)do? Nothing extra: it passes the first failure through. - How is a network publisher different?
URLSession.dataTaskPublisheris lazy (a new task per subscription), so retry works on it withoutDeferred.
Related questions
- 11.8.1 When does a Future run its work, what do two subscribers get, and what does Deferred change?
- 11.7.3 Where should a catch go in a search pipeline, and what happens to the stream if you put it outside the…
- 11.5.2 How do failures work in Combine, what does Never mean, and how do catch, replaceError, retry and mapError…
- 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…
← Where should a catch go in a search pipeline, and what happens to the… How do you see what a Combine pipeline is doing: handleEvents, print… →