Why does retry sometimes not retry anything, and how do Future and Deferred differ here?

11.7.4 · L3 · Interview

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 retry after a Future and 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.dataTaskPublisher is lazy (a new task per subscription), so retry works on it without Deferred.

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… →